Files
invyone/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java
T
hjjeong 46707bd116 feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
기존엔 PostgreSQL 만 테스트 가능했고, V001 (SERIAL→VARCHAR) 마이그레이션 이후 회사 프로비저닝 시 시퀀스가 max(id) 보다 작은 상태로 남아있어서 새 외부 커넥션 등록 시 duplicate key 가 재발하던 문제 해결.

backend
- build.gradle: MariaDB/MySQL/MSSQL/SQLite JDBC 드라이버 4종 runtimeOnly 추가
- ExternalDbConnectionService.executeConnectionTest: PostgreSQL-only 가드 제거, dbType 별 JDBC URL/props 분기 구현 (postgresql/mysql/mariadb/mssql/sqlite). defaultPort helper 추가
- mapper/externalDbConnection.xml: INSERT/UPDATE 의 port/connection_timeout/query_timeout/max_connections 에 ::varchar 캐스팅 추가 (V001 으로 VARCHAR 가 됐는데 클라가 숫자로 보내서 character varying = bigint 비교 불가로 500 나던 것)
- DataCopier.resetSequences: VARCHAR PK + 시퀀스 의존성이 남은 컬럼도 setval 대상에 포함. MAX(col::bigint) + col ~ '^[0-9]+$' 정규식으로 type mismatch 회피하면서 숫자형 VARCHAR PK 만 안전하게 reset

frontend
- ExternalDbConnectionModal: DialogContent 를 flex 컬럼으로, 본문에 자체 스크롤 + Footer shrink-0 → 길어진 폼에서도 취소/생성 버튼이 항상 보이도록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:01:14 +09:00

173 lines
7.9 KiB
Java

package com.erp.provisioning;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 선택된 테이블을 원본 DB → 신규 DB 로 복사.
*
* 정책:
* - company_code 컬럼이 있는 테이블: ('*','TEMPLATE') + NULL 만 복사 (공통 템플릿).
* - 없는 테이블: 전체 복사.
*
* ★ 실패 시: 테이블 하나라도 예외가 터지면 호출자에게 그대로 throw.
* CompanyProvisioningService 가 dst.rollback() + DROP DATABASE 로 원자성 보장.
*
* ★ 2026-04-24 추가: resetSequences() — 데이터 복사 후 모든 시퀀스의 current value 를
* max(column)+1 로 재설정. pg_dump --schema-only 는 시퀀스 값을 안 복제하므로 필수.
*/
@Service
@Slf4j
public class DataCopier {
private static final int BATCH = 500;
public int copyTable(Connection src, Connection dst, String table) throws SQLException {
boolean hasCompanyCode = columnExists(src, table, "company_code");
List<String> cols = listColumns(src, table);
if (cols.isEmpty()) {
log.warn("[Provisioning] {} has no columns in source — skip", table);
return 0;
}
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ")";
int rows = 0;
try (PreparedStatement selectStmt = src.prepareStatement(selectSql);
ResultSet rs = selectStmt.executeQuery();
PreparedStatement insertStmt = dst.prepareStatement(insertSql)) {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
while (rs.next()) {
for (int i = 1; i <= colCount; i++) {
insertStmt.setObject(i, rs.getObject(i));
}
insertStmt.addBatch();
rows++;
if (rows % BATCH == 0) insertStmt.executeBatch();
}
insertStmt.executeBatch();
}
log.info("[Provisioning] COPY DATA {}: {} rows (filter={})", table, rows, hasCompanyCode);
return rows;
}
/**
* 모든 시퀀스의 current value 를 연결된 컬럼의 MAX()+1 로 재설정.
* pg_depend 조회로 seq↔table↔column 관계를 얻으므로 휴리스틱 없음.
*
* 이 쿼리의 테이블/컬럼/시퀀스 이름은 pg_class 에서 직접 나온 값이라 신뢰 가능 → SQL injection 위험 없음.
* 빈 테이블은 setval(seq, 1) 로 안전하게 초기화.
*/
public int resetSequences(Connection dst) throws SQLException {
// format_type 으로 컬럼 실제 타입까지 조회 → integer 계열만 setval.
// 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음 (시퀀스 의존성만 남음).
// 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
String findSql = """
SELECT s.relname AS seq, t.relname AS tbl, a.attname AS col,
format_type(a.atttypid, a.atttypmod) AS coltype
FROM pg_class s
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S' AND d.deptype = 'a'
""";
List<String[]> rows = new ArrayList<>();
try (Statement s = dst.createStatement();
ResultSet rs = s.executeQuery(findSql)) {
while (rs.next()) {
rows.add(new String[]{
rs.getString("seq"), rs.getString("tbl"),
rs.getString("col"), rs.getString("coltype")});
}
}
int updated = 0, skippedType = 0, skippedErr = 0;
try (Statement us = dst.createStatement()) {
for (String[] r : rows) {
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
String sql;
if (isIntegerLike(coltype)) {
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
} else if (isVarcharLike(coltype)) {
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
seq.replace("'", "''"), col, tbl, col);
} else {
skippedType++;
continue;
}
try {
us.execute(sql);
updated++;
} catch (SQLException e) {
skippedErr++;
log.warn("[Provisioning] setval failed seq={} tbl={} col={} type={}: {}",
seq, tbl, col, coltype, e.getMessage());
}
}
}
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
return updated;
}
private static boolean isIntegerLike(String coltype) {
if (coltype == null) return false;
String t = coltype.toLowerCase();
return t.startsWith("integer") || t.startsWith("bigint") || t.startsWith("smallint")
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
}
private static boolean isVarcharLike(String coltype) {
if (coltype == null) return false;
String t = coltype.toLowerCase();
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
}
private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
"ORDER BY ordinal_position")) {
ps.setString(1, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) cols.add(rs.getString(1));
}
}
return cols;
}
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT 1 FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
ps.setString(1, table);
ps.setString(2, col);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
}