package egovframework.config; import com.zaxxer.hikari.HikariDataSource; import net.sf.jsqlparser.expression.CaseExpression; import net.sf.jsqlparser.expression.CastExpression; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.WhenClause; import net.sf.jsqlparser.expression.operators.conditional.AndExpression; import net.sf.jsqlparser.expression.operators.conditional.OrExpression; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.select.*; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.update.UpdateSet; import net.ttddyy.dsproxy.ExecutionInfo; import net.ttddyy.dsproxy.QueryInfo; import net.ttddyy.dsproxy.listener.QueryExecutionListener; import net.ttddyy.dsproxy.listener.logging.DefaultQueryLogEntryCreator; import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel; import net.ttddyy.dsproxy.listener.logging.SLF4JQueryLoggingListener; import net.ttddyy.dsproxy.proxy.ParameterSetOperation; import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; import javax.sql.DataSource; import java.util.List; /** * DataSource Proxy 설정 클래스 * * datasource-proxy 라이브러리를 사용하여 파라미터 바인딩이 된 실제 SQL 쿼리를 로그로 출력하도록 설정 * MyBatis의 include, foreach 등 복잡한 쿼리에도 적용됨 * * Environment를 사용하여 수동으로 DataSource를 구성하고 프록시 적용 * Multi DB 환경 지원 * * @author XIT Framework */ @Configuration public class DataSourceProxyConfig { @Autowired private Environment environment; /** * Primary 데이터소스 빈 생성 * Environment에서 설정값을 읽어서 HikariDataSource를 수동으로 구성 */ @Bean public DataSource actualDataSource() { return createHikariDataSource("spring.datasource"); } /** * Secondary 데이터소스 빈 생성 (필요한 경우) * Multi DB 환경에서 두 번째 DB를 사용할 경우 활성화 * * 예시: * @Bean * public DataSource actualSecondaryDataSource() { * return createHikariDataSource("spring.datasource.secondary"); * } */ /** * HikariDataSource 생성 헬퍼 메서드 * * @param prefix application.yml의 설정 prefix * @return HikariDataSource */ private HikariDataSource createHikariDataSource(String prefix) { HikariDataSource dataSource = new HikariDataSource(); // application.yml에서 설정값 읽기 dataSource.setJdbcUrl(environment.getProperty(prefix + ".url")); dataSource.setUsername(environment.getProperty(prefix + ".username")); dataSource.setPassword(environment.getProperty(prefix + ".password")); dataSource.setDriverClassName(environment.getProperty(prefix + ".driver-class-name")); // HikariCP 설정 dataSource.setMaximumPoolSize(environment.getProperty(prefix + ".hikari.maximum-pool-size", Integer.class, 10)); dataSource.setMinimumIdle(environment.getProperty(prefix + ".hikari.minimum-idle", Integer.class, 5)); dataSource.setConnectionTimeout(environment.getProperty(prefix + ".hikari.connection-timeout", Long.class, 30000L)); dataSource.setIdleTimeout(environment.getProperty(prefix + ".hikari.idle-timeout", Long.class, 600000L)); dataSource.setMaxLifetime(environment.getProperty(prefix + ".hikari.max-lifetime", Long.class, 1800000L)); dataSource.setValidationTimeout(environment.getProperty(prefix + ".hikari.validation-timeout", Long.class, 60000L)); return dataSource; } /** * Primary 프록시 데이터소스 빈 생성 * actualDataSource를 래핑하여 SQL 쿼리 로깅 기능을 추가 * * @param actualDataSource 실제 데이터소스 * @return 프록시가 적용된 데이터소스 */ @Bean @Primary public DataSource dataSource(@Qualifier("actualDataSource") DataSource actualDataSource) { return createProxyDataSource(actualDataSource, "PRIMARY-DB"); } /** * Secondary 프록시 데이터소스 빈 생성 (필요한 경우) * Multi DB 환경에서 두 번째 DB를 사용할 경우 활성화 * * 예시: * @Bean * public DataSource secondaryDataSource(@Qualifier("actualSecondaryDataSource") DataSource actualSecondaryDataSource) { * return createProxyDataSource(actualSecondaryDataSource, "SECONDARY-DB"); * } */ /** * 프록시 데이터소스 생성 헬퍼 메서드 * * @param actualDataSource 실제 데이터소스 * @param dataSourceName 데이터소스 이름 (로그 식별용) * @return 프록시가 적용된 데이터소스 */ private DataSource createProxyDataSource(DataSource actualDataSource, String dataSourceName) { // SLF4J 쿼리 로깅 리스너 생성 SLF4JQueryLoggingListener loggingListener = new SLF4JQueryLoggingListener(); // 로그 레벨 설정 (DEBUG 레벨로 출력) loggingListener.setLogLevel(SLF4JLogLevel.DEBUG); // 로거 이름 설정 (쿼리 로그 식별을 위함) loggingListener.setLogger("go.kr.project.sql"); // 쿼리 로그 엔트리 생성자 설정 DefaultQueryLogEntryCreator logEntryCreator = new DefaultQueryLogEntryCreator(); logEntryCreator.setMultiline(true); // 멀티라인으로 보기 좋게 출력 loggingListener.setQueryLogEntryCreator(logEntryCreator); // 커스텀 파라미터 바인딩 리스너 생성 CustomParameterBindingListener customListener = new CustomParameterBindingListener(); // 프록시 데이터소스 빌더를 사용하여 프록시 데이터소스 생성 return ProxyDataSourceBuilder .create(actualDataSource) .name(dataSourceName) // 데이터소스 이름 설정 .listener(loggingListener) // 기본 로깅 리스너 추가 .listener(customListener) // 커스텀 파라미터 바인딩 리스너 추가 .asJson() // JSON 형태로 파라미터 바인딩된 쿼리 출력 .build(); } /** * 파라미터가 바인딩된 실제 SQL 쿼리를 출력하는 커스텀 리스너 */ private static class CustomParameterBindingListener implements QueryExecutionListener { private static final Logger logger = LoggerFactory.getLogger("go.kr.project.sql.binding"); @Override public void beforeQuery(ExecutionInfo execInfo, List queryInfoList) { // 쿼리 실행 전 처리 (필요시 구현) } @Override public void afterQuery(ExecutionInfo execInfo, List queryInfoList) { try { // 쿼리 실행 후 파라미터가 바인딩된 SQL 출력 for (QueryInfo queryInfo : queryInfoList) { String query = queryInfo.getQuery(); List> parametersList = queryInfo.getParametersList(); // Mapper 경로 및 메서드명 추출 String mapperInfo = extractMapperInfo(); logger.info(" ========================== Mapper: {} ========================== ", mapperInfo); // 쿼리 실행 시간 및 기본 정보 long executionTime = execInfo.getElapsedTime(); logger.debug("실행 시간: {}ms", executionTime); // 파라미터 값 추출 List parameterValues = extractParameterValues(parametersList); // 파라미터 개수 정보 int questionMarkCount = countQuestionMarks(query); logger.debug("파라미터 정보: ? 플레이스홀더 {}개, 바인딩 값 {}개", questionMarkCount, parameterValues.size()); // 파라미터 값들 전체 출력 (인덱스 포함) if (!parameterValues.isEmpty()) { logger.debug("파라미터 값 목록 (총 {}개):", parameterValues.size()); for (int i = 0; i < parameterValues.size(); i++) { Object param = parameterValues.get(i); String paramValue; if (param == null) { paramValue = "NULL"; } else if (param instanceof String) { paramValue = "'" + param.toString() + "'"; } else { paramValue = param.toString(); } // 파라미터 인덱스와 함께 출력 logger.debug(" (parameter {}) = {}", i + 1, paramValue); } } // 파라미터 바인딩된 SQL 생성 String boundQuery = bindParameters(query, parameterValues); // SQL 포맷팅 적용 (jsqlparser 사용) String formattedQuery = formatSql(boundQuery); logger.info("\n{}\n", formattedQuery); } } finally { // ThreadLocal 정리 (메모리 누수 방지) // 쿼리 로깅이 완료된 후 반드시 정리 SqlLoggingInterceptor.clearCurrentMapperInfo(); } } /** * SQL 쿼리에서 ? 플레이스홀더 개수를 카운트하는 메서드 * 주석 안의 ? 는 제외하고 실제 파라미터 플레이스홀더만 카운트 * * @param query SQL 쿼리 문자열 * @return ? 플레이스홀더 개수 */ private int countQuestionMarks(String query) { int count = 0; boolean inBlockComment = false; // /* */ 블록 주석 내부인지 여부 boolean inLineComment = false; // -- 라인 주석 내부인지 여부 boolean inStringLiteral = false; // ' ' 문자열 리터럴 내부인지 여부 for (int i = 0; i < query.length(); i++) { char currentChar = query.charAt(i); char nextChar = (i + 1 < query.length()) ? query.charAt(i + 1) : '\0'; // 문자열 리터럴 처리 (홑따옴표로 둘러싸인 문자열) if (currentChar == '\'' && !inBlockComment && !inLineComment) { inStringLiteral = !inStringLiteral; continue; } // 블록 주석 시작 /* 검사 if (!inStringLiteral && !inLineComment && currentChar == '/' && nextChar == '*') { inBlockComment = true; i++; // 다음 문자도 건너뛰기 continue; } // 블록 주석 끝 */ 검사 if (!inStringLiteral && inBlockComment && currentChar == '*' && nextChar == '/') { inBlockComment = false; i++; // 다음 문자도 건너뛰기 continue; } // 라인 주석 시작 -- 검사 if (!inStringLiteral && !inBlockComment && currentChar == '-' && nextChar == '-') { inLineComment = true; i++; // 다음 문자도 건너뛰기 continue; } // 라인 주석 끝 (줄바꿈) 검사 if (inLineComment && (currentChar == '\n' || currentChar == '\r')) { inLineComment = false; continue; } // ? 플레이스홀더 카운트 (주석이나 문자열 리터럴 내부가 아닌 경우에만) if (currentChar == '?' && !inBlockComment && !inLineComment && !inStringLiteral) { count++; } } return count; } /** * MyBatis Interceptor에서 정확한 Mapper 정보 추출 * SqlLoggingInterceptor에서 ThreadLocal을 통해 제공하는 정보를 사용 * * @return Mapper 클래스명과 메서드명 (예: go.kr.project.login.mapper.LoginMapper.selectMenusByRoleIds) */ private String extractMapperInfo() { try { // SqlLoggingInterceptor에서 제공하는 정확한 Mapper 정보 사용 String mapperInfo = SqlLoggingInterceptor.getCurrentMapperInfo(); return mapperInfo != null ? mapperInfo : "Unknown Mapper"; } catch (Exception e) { logger.warn("Mapper 정보 추출 실패: {}", e.getMessage()); return "Unknown Mapper"; } } /** * ParameterSetOperation 리스트에서 실제 파라미터 값들을 추출 * 모든 객체 타입(Integer, String, Date 등)의 null 값을 올바르게 처리하도록 완전 재구현 */ private List extractParameterValues(List> parametersList) { java.util.Map parameterMap = new java.util.TreeMap<>(); if (parametersList != null && !parametersList.isEmpty()) { // 첫 번째 파라미터 세트를 사용 (일반적으로 PreparedStatement는 하나의 파라미터 세트를 가짐) List operations = parametersList.get(0); if (operations != null) { for (ParameterSetOperation operation : operations) { Object[] args = operation.getArgs(); if (args != null && args.length >= 1) { Integer paramIndex = (Integer) args[0]; Object paramValue = null; // 리플렉션을 사용하여 실제 메서드명 추출 String methodName = extractMethodName(operation); //logger.debug("[METHOD_INFO] 파라미터 {}번 - 메서드명: {}, args 길이: {}", // paramIndex, methodName, args.length); // 메서드명을 통한 정확한 null 처리 if ("setNull".equals(methodName)) { // setNull 메서드 호출 - 확실하게 null 값으로 설정 paramValue = null; //logger.debug("[NULL_SET] setNull 메서드 호출됨 - 파라미터 {}번을 null로 설정", paramIndex); } else { // 다른 모든 set 메서드들 (setString, setInt, setObject 등) if (args.length >= 2) { paramValue = args[1]; // args[1]이 null인 경우에도 null로 처리 if (paramValue == null) { //logger.debug("[NULL_VALUE] 파라미터 {}번 값이 null임", paramIndex); } else { //logger.debug("[VALUE_SET] 파라미터 {}번 - 메서드: {}, 값: {} (타입: {})", // paramIndex, methodName, paramValue, paramValue.getClass().getSimpleName()); } } else { logger.warn("[WARN] 파라미터 {}번 - 메서드: {}, args 길이 부족: {}", paramIndex, methodName, args.length); } } parameterMap.put(paramIndex, paramValue); } } } } // TreeMap을 사용했으므로 자동으로 인덱스 순서대로 정렬됨 return new java.util.ArrayList<>(parameterMap.values()); } /** * ParameterSetOperation에서 실제 호출된 메서드명을 추출 * 리플렉션을 사용하여 정확한 메서드명을 획득 */ private String extractMethodName(ParameterSetOperation operation) { try { // ParameterSetOperation의 getMethod() 메서드 호출을 시도 java.lang.reflect.Method getMethodMethod = operation.getClass().getMethod("getMethod"); Object method = getMethodMethod.invoke(operation); if (method instanceof java.lang.reflect.Method) { return ((java.lang.reflect.Method) method).getName(); } // getMethod가 없거나 실패한 경우 toString()에서 메서드명 추출 시도 String operationStr = operation.toString(); if (operationStr != null) { // "MethodName(args...)" 형태에서 메서드명 추출 int openParen = operationStr.indexOf('('); if (openParen > 0) { String methodPart = operationStr.substring(0, openParen); int lastDot = methodPart.lastIndexOf('.'); return lastDot >= 0 ? methodPart.substring(lastDot + 1) : methodPart; } } } catch (Exception e) { logger.debug("[METHOD_EXTRACT_ERROR] 메서드명 추출 실패: {}", e.getMessage()); } return "unknown"; } /** * SQL을 보기 좋게 포맷팅합니다. * 주석(/ * * /)을 원래 위치에 보존하면서 포맷팅합니다. * * @param sql 포맷팅할 SQL 문자열 * @return 포맷팅된 SQL 문자열 */ private String formatSql(String sql) { // 주석이 포함된 경우 원본 SQL을 그대로 반환 (주석 위치 보존) if (sql.contains("/*")) { return sql; } // 주석이 없는 경우에만 jsqlparser로 포맷팅 try { Statement statement = CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { return formatSelectStatement((Select) statement); } else if (statement instanceof Insert) { return formatInsertStatement((Insert) statement); } else if (statement instanceof Update) { return formatUpdateStatement((Update) statement); } else { // SELECT, INSERT, UPDATE 외 다른 구문(DELETE 등)은 기본 포맷팅 적용 return applyBasicFormatting(statement.toString()); } } catch (Exception e) { // SQL 파싱 실패 시 원본 SQL 반환 logger.info("SQL 파싱 실패, 원본 SQL 반환: {}", e.getMessage()); return sql; } } /** * UPDATE 구문을 AST 기반으로 포맷팅합니다. */ private String formatUpdateStatement(Update update) { StringBuilder sb = new StringBuilder(); sb.append("UPDATE ").append(update.getTable()); // SET clause if (update.getUpdateSets() != null && !update.getUpdateSets().isEmpty()) { sb.append("\nSET"); List updateSets = update.getUpdateSets(); for (int i = 0; i < updateSets.size(); i++) { if (i > 0) { sb.append(","); } UpdateSet set = updateSets.get(i); sb.append("\n "); if (set.getColumns().size() > 1) sb.append("("); for (int j = 0; j < set.getColumns().size(); j++) { if (j > 0) sb.append(", "); sb.append(set.getColumns().get(j)); } if (set.getColumns().size() > 1) sb.append(")"); sb.append(" = "); if (set.getExpressions().size() > 1) sb.append("("); for (int j = 0; j < set.getExpressions().size(); j++) { if (j > 0) sb.append(", "); sb.append(formatComplexExpression(set.getExpressions().get(j), 2)); } if (set.getExpressions().size() > 1) sb.append(")"); } } // FROM clause (for some dialects) if (update.getFromItem() != null) { sb.append("\nFROM ").append(update.getFromItem()); } // JOIN clause (for some dialects) if (update.getJoins() != null) { for (Join join : update.getJoins()) { sb.append("\n").append(formatJoin(join)); } } // WHERE clause if (update.getWhere() != null) { sb.append("\nWHERE"); appendExpression(update.getWhere(), sb, 1, false); } return sb.toString(); } /** * INSERT 구문을 AST 기반으로 포맷팅합니다. * 컬럼 목록과 값 목록을 줄바꿈하여 가독성을 높입니다. * INSERT ... SELECT 구문도 지원합니다. */ private String formatInsertStatement(Insert insert) { StringBuilder sb = new StringBuilder(); sb.append("INSERT INTO ").append(insert.getTable()); // 컬럼 목록 포맷팅 if (insert.getColumns() != null && !insert.getColumns().isEmpty()) { sb.append("("); for (int i = 0; i < insert.getColumns().size(); i++) { sb.append("\n ").append(insert.getColumns().get(i).getColumnName()); if (i < insert.getColumns().size() - 1) { sb.append(","); } } sb.append("\n)"); } // 값 목록 포맷팅 if (insert.getItemsList() != null) { if (insert.getItemsList() instanceof SubSelect) { // INSERT ... SELECT 구문인 경우 sb.append("\n"); SubSelect subSelect = (SubSelect) insert.getItemsList(); formatSelectBody(subSelect.getSelectBody(), sb, 0); } else { sb.append("\nVALUES\n("); if (insert.getItemsList() instanceof ExpressionList) { List values = ((ExpressionList) insert.getItemsList()).getExpressions(); for (int i = 0; i < values.size(); i++) { sb.append("\n ").append(formatComplexExpression(values.get(i), 2)); if (i < values.size() - 1) { sb.append(","); } } } else { sb.append("\n ").append(insert.getItemsList().toString()); } sb.append("\n)"); } } return sb.toString(); } /** * SELECT 구문을 AST 기반으로 포맷팅합니다. * * @param select JSqlParser의 Select 객체 * @return 포맷팅된 SELECT 구문 */ private String formatSelectStatement(Select select) { StringBuilder sb = new StringBuilder(); SelectBody selectBody = select.getSelectBody(); // 재귀적으로 SelectBody를 처리 (UNION 등 복합 쿼리 지원) formatSelectBody(selectBody, sb, 0); return sb.toString(); } /** * SelectBody를 재귀적으로 포맷팅하여 UNION 등의 복합 쿼리를 처리합니다. * * @param selectBody SelectBody 객체 * @param sb 결과를 담을 StringBuilder * @param indentLevel 들여쓰기 수준 */ private void formatSelectBody(SelectBody selectBody, StringBuilder sb, int indentLevel) { String indent = getIndent(indentLevel); if (selectBody instanceof PlainSelect) { PlainSelect plainSelect = (PlainSelect) selectBody; // SELECT 절 sb.append(indent).append("SELECT"); if (plainSelect.getDistinct() != null) { sb.append(" ").append(plainSelect.getDistinct()); } appendSelectItems(plainSelect.getSelectItems(), sb, indentLevel + 1); // FROM 절 if (plainSelect.getFromItem() != null) { sb.append("\n").append(indent).append("FROM ").append(plainSelect.getFromItem()); } // JOIN 절 if (plainSelect.getJoins() != null) { for (Join join : plainSelect.getJoins()) { sb.append("\n").append(indent).append(formatJoin(join)); } } // WHERE 절 if (plainSelect.getWhere() != null) { sb.append("\n").append(indent).append("WHERE"); appendExpression(plainSelect.getWhere(), sb, indentLevel + 1, false); } // GROUP BY 절 if (plainSelect.getGroupBy() != null) { sb.append("\n").append(indent).append("GROUP BY ").append(plainSelect.getGroupBy().toString()); } // HAVING 절 if (plainSelect.getHaving() != null) { sb.append("\n").append(indent).append("HAVING"); appendExpression(plainSelect.getHaving(), sb, indentLevel + 1, false); } // ORDER BY 절 if (plainSelect.getOrderByElements() != null && !plainSelect.getOrderByElements().isEmpty()) { sb.append("\n").append(indent).append("ORDER BY "); List orderByElements = plainSelect.getOrderByElements(); for (int i = 0; i < orderByElements.size(); i++) { if (i > 0) { sb.append(", "); } OrderByElement element = orderByElements.get(i); sb.append(element.getExpression().toString()); if (element.isAsc()) { sb.append(" ASC"); } else if (!element.isAsc() && element.toString().contains("DESC")) { sb.append(" DESC"); } } } // LIMIT 절 if (plainSelect.getLimit() != null) { sb.append("\n").append(indent).append(plainSelect.getLimit()); } } else if (selectBody instanceof SetOperationList) { SetOperationList setOpList = (SetOperationList) selectBody; for (int i = 0; i < setOpList.getSelects().size(); i++) { if (i > 0) { sb.append("\n").append(indent).append(setOpList.getOperations().get(i - 1)).append("\n"); } formatSelectBody(setOpList.getSelects().get(i), sb, indentLevel); } } else { // 기타 SelectBody 타입은 기본 toString() 사용 sb.append(indent).append(selectBody.toString()); } } /** * SELECT 항목들을 포맷팅하여 추가합니다. */ private void appendSelectItems(List selectItems, StringBuilder sb, int indentLevel) { String indent = getIndent(indentLevel); for (int i = 0; i < selectItems.size(); i++) { sb.append("\n").append(indent); if (i > 0) { sb.append(", "); } SelectItem item = selectItems.get(i); if (item instanceof SelectExpressionItem) { SelectExpressionItem exprItem = (SelectExpressionItem) item; // CASE 표현식 등을 포함한 복잡한 표현식 포맷팅 sb.append(formatComplexExpression(exprItem.getExpression(), indentLevel)); if (exprItem.getAlias() != null) { sb.append(" AS ").append(exprItem.getAlias().getName()); } } else { sb.append(item.toString()); } } } /** * 조건 표현식(WHERE, HAVING)을 포맷팅하여 추가합니다. * AND, OR를 기준으로 줄바꿈 및 들여쓰기를 적용합니다. */ private void appendExpression(Expression expression, StringBuilder sb, int indentLevel, boolean isNested) { String indent = getIndent(indentLevel); if (expression instanceof AndExpression) { AndExpression and = (AndExpression) expression; appendExpression(and.getLeftExpression(), sb, indentLevel, isNested); sb.append("\n").append(indent).append("AND "); appendExpression(and.getRightExpression(), sb, indentLevel, true); } else if (expression instanceof OrExpression) { OrExpression or = (OrExpression) expression; appendExpression(or.getLeftExpression(), sb, indentLevel, isNested); sb.append("\n").append(indent).append("OR "); appendExpression(or.getRightExpression(), sb, indentLevel, true); } else { if (!isNested) { sb.append("\n").append(indent); } sb.append(formatComplexExpression(expression, indentLevel)); } } /** * CASE와 같은 복잡한 표현식을 포맷팅합니다. */ private String formatComplexExpression(Expression expression, int indentLevel) { if (expression instanceof CaseExpression) { return formatCaseExpression((CaseExpression) expression, indentLevel); } if (expression instanceof CastExpression) { return formatCastExpression((CastExpression) expression, indentLevel); } // 다른 복잡한 표현식들도 여기에 추가 가능 return expression.toString(); } /** * CASE 표현식을 포맷팅합니다. */ private String formatCaseExpression(CaseExpression caseExpr, int indentLevel) { String indent = getIndent(indentLevel); String innerIndent = getIndent(indentLevel + 1); StringBuilder sb = new StringBuilder("CASE"); if (caseExpr.getSwitchExpression() != null) { sb.append(" ").append(caseExpr.getSwitchExpression().toString()); } for (WhenClause when : caseExpr.getWhenClauses()) { sb.append("\n").append(innerIndent).append("WHEN ").append(when.getWhenExpression().toString()); sb.append(" THEN ").append(when.getThenExpression().toString()); } if (caseExpr.getElseExpression() != null) { sb.append("\n").append(innerIndent).append("ELSE ").append(caseExpr.getElseExpression().toString()); } sb.append("\n").append(indent).append("END"); return sb.toString(); } /** * CAST 표현식을 포맷팅합니다. */ private String formatCastExpression(CastExpression castExpr, int indentLevel) { return "CAST(" + formatComplexExpression(castExpr.getLeftExpression(), indentLevel) + " AS " + castExpr.getType().toString() + ")"; } /** * JOIN 구문을 포맷팅합니다. */ private String formatJoin(Join join) { String joinType = ""; if (join.isSimple()) joinType += " "; if (join.isCross()) joinType += "CROSS "; if (join.isFull()) joinType += "FULL "; if (join.isInner()) joinType += "INNER "; if (join.isLeft()) joinType += "LEFT "; if (join.isNatural()) joinType += "NATURAL "; if (join.isOuter()) joinType += "OUTER "; if (join.isRight()) joinType += "RIGHT "; if (join.isSemi()) joinType += "SEMI "; return joinType + "JOIN " + join.getRightItem() + (join.getOnExpression() != null ? " ON " + join.getOnExpression() : ""); } /** * INSERT, UPDATE, DELETE 등의 기본 포맷팅을 적용합니다. */ private String applyBasicFormatting(String sql) { return sql.replaceAll("(?i)\\b(SET|VALUES|WHERE)\\b", "\\n$1"); } /** * 들여쓰기 문자열을 생성합니다. */ private String getIndent(int level) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < level; i++) { sb.append(" "); // 4 spaces for each level } return sb.toString(); } /** * SQL 쿼리의 ? 플레이스홀더를 실제 파라미터 값으로 치환 * 주석 안에 있는 ? 는 치환하지 않음 */ private String bindParameters(String query, List parameters) { if (parameters == null || parameters.isEmpty()) { return query; } StringBuilder result = new StringBuilder(); int paramIndex = 0; int queryIndex = 0; boolean inBlockComment = false; // /* */ 블록 주석 내부인지 여부 boolean inLineComment = false; // -- 라인 주석 내부인지 여부 boolean inStringLiteral = false; // ' ' 문자열 리터럴 내부인지 여부 while (queryIndex < query.length()) { char currentChar = query.charAt(queryIndex); char nextChar = (queryIndex + 1 < query.length()) ? query.charAt(queryIndex + 1) : '\0'; // 문자열 리터럴 처리 (홑따옴표로 둘러싸인 문자열) if (currentChar == '\'' && !inBlockComment && !inLineComment) { inStringLiteral = !inStringLiteral; result.append(currentChar); queryIndex++; continue; } // 블록 주석 시작 /* 검사 if (!inStringLiteral && !inLineComment && currentChar == '/' && nextChar == '*') { inBlockComment = true; result.append(currentChar); result.append(nextChar); queryIndex += 2; continue; } // 블록 주석 끝 */ 검사 if (!inStringLiteral && inBlockComment && currentChar == '*' && nextChar == '/') { inBlockComment = false; result.append(currentChar); result.append(nextChar); queryIndex += 2; continue; } // 라인 주석 시작 -- 검사 if (!inStringLiteral && !inBlockComment && currentChar == '-' && nextChar == '-') { inLineComment = true; result.append(currentChar); result.append(nextChar); queryIndex += 2; continue; } // 라인 주석 끝 (줄바꿈) 검사 if (inLineComment && (currentChar == '\n' || currentChar == '\r')) { inLineComment = false; result.append(currentChar); queryIndex++; continue; } // ? 파라미터 플레이스홀더 처리 (주석이나 문자열 리터럴 내부가 아닌 경우에만) if (currentChar == '?' && !inBlockComment && !inLineComment && !inStringLiteral) { if (paramIndex < parameters.size()) { // ? 플레이스홀더를 실제 파라미터 값으로 치환 Object param = parameters.get(paramIndex); String paramValue; if (param == null) { paramValue = "NULL"; } else if (param instanceof String) { paramValue = "'" + param.toString().replace("'", "''") + "'"; } else if (param instanceof java.util.Date || param instanceof java.time.LocalDateTime || param instanceof java.time.LocalDate) { paramValue = "'" + param.toString() + "'"; } else { paramValue = param.toString(); } result.append(paramValue); paramIndex++; } else { // 파라미터가 부족한 경우 ? 그대로 유지 result.append('?'); } } else { // 그 외의 모든 문자는 그대로 복사 result.append(currentChar); } queryIndex++; } return result.toString(); } } }