From 77232f4bf346178a17c7179e681a887098cdde55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=98=81?= Date: Wed, 20 Aug 2025 10:00:49 +0900 Subject: [PATCH] =?UTF-8?q?DataSource=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20MyBatis=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../egovframework/config/MyBatisConfig.java | 79 +- .../config/MyBatisQueryInterceptor.java | 692 ++++-------------- src/main/resources/application-local.yml | 1 + src/main/webapp/WEB-INF/views/login/login.jsp | 2 +- 5 files changed, 220 insertions(+), 558 deletions(-) diff --git a/build.gradle b/build.gradle index dce72f6..a3d4bb6 100644 --- a/build.gradle +++ b/build.gradle @@ -132,6 +132,10 @@ dependencies { // ===== sqlPaser ===== implementation 'com.github.jsqlparser:jsqlparser:4.5' + // ===== DataSource Proxy ===== + // datasource-proxy - 쿼리 로깅 및 모니터링을 위한 데이터소스 프록시 + implementation 'net.ttddyy:datasource-proxy:1.8.1' + // ===== 개발 도구 의존성 ===== // Lombok - 반복 코드 생성 도구 (Getter, Setter, Builder 등 자동 생성) compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/egovframework/config/MyBatisConfig.java b/src/main/java/egovframework/config/MyBatisConfig.java index eb6adda..bf869c9 100644 --- a/src/main/java/egovframework/config/MyBatisConfig.java +++ b/src/main/java/egovframework/config/MyBatisConfig.java @@ -1,27 +1,88 @@ package egovframework.config; +import lombok.extern.slf4j.Slf4j; +import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel; +import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.Primary; + +import javax.sql.DataSource; /** - * MyBatis Configuration + * MyBatis Configuration with DataSource Proxy * - * This class configures MyBatis by adding custom interceptors. + * datasource-proxy를 이용하여 동적 쿼리와 복잡한 include된 쿼리의 + * 파라미터 바인딩된 결과를 로깅하도록 설정 */ +@Slf4j @Configuration -@Profile({"local", "dev"}) // local과 dev 프로파일에서만 활성화 +//@Profile({"local", "dev"}) // local과 dev 프로파일에서만 활성화 public class MyBatisConfig { /** - * Customizes the MyBatis configuration by adding the query interceptor. + * 기본 DataSource Properties 설정 + */ + @Bean + @Primary + @ConfigurationProperties("spring.datasource") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + /** + * 실제 DataSource 생성 (프록시되지 않은 원본) + */ + @Bean + @ConfigurationProperties("spring.datasource.hikari") + public DataSource actualDataSource(DataSourceProperties properties) { + return properties.initializeDataSourceBuilder().build(); + } + + /** + * DataSource Proxy 설정 + * + * datasource-proxy를 사용하여 실제 쿼리와 파라미터 바인딩 결과를 로깅 + * - 동적 쿼리 (if, choose 등) 처리된 결과 로깅 + * - include된 복잡한 쿼리 결과 로깅 + * - 파라미터 바인딩된 최종 쿼리 로깅 + */ + @Bean + @Primary + public DataSource dataSource(@Qualifier("actualDataSource") DataSource actualDataSource) { + return ProxyDataSourceBuilder + .create(actualDataSource) + .name("XIT-Framework-DataSource") + // 쿼리 로깅 리스너 설정 - 실행된 모든 쿼리와 파라미터 바인딩 결과 로깅 + .logQueryBySlf4j(SLF4JLogLevel.INFO, "go.kr.project.sql.query") + // 멀티라인으로 쿼리 포맷팅하여 가독성 향상 + .multiline() + .build(); + } + + /** + * MyBatis Query Interceptor 주입 + */ + @Autowired + private MyBatisQueryInterceptor myBatisQueryInterceptor; + + /** + * MyBatis Configuration Customizer * - * @param interceptor The MyBatis query interceptor - * @return A ConfigurationCustomizer that adds the interceptor to MyBatis + * MyBatis에 커스텀 인터셉터를 추가하여 + * datasource-proxy와 함께 상세한 쿼리 분석 제공 */ @Bean - public ConfigurationCustomizer mybatisConfigurationCustomizer(MyBatisQueryInterceptor interceptor) { - return configuration -> configuration.addInterceptor(interceptor); + public ConfigurationCustomizer mybatisConfigurationCustomizer() { + return configuration -> { + // 커스텀 쿼리 인터셉터 추가 + configuration.addInterceptor(myBatisQueryInterceptor); + log.info("MyBatis Query Interceptor가 등록되었습니다 - 상세 쿼리 분석 활성화"); + }; } } \ No newline at end of file diff --git a/src/main/java/egovframework/config/MyBatisQueryInterceptor.java b/src/main/java/egovframework/config/MyBatisQueryInterceptor.java index b5fd0b3..c07c094 100644 --- a/src/main/java/egovframework/config/MyBatisQueryInterceptor.java +++ b/src/main/java/egovframework/config/MyBatisQueryInterceptor.java @@ -13,7 +13,6 @@ import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.select.SelectItem; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.update.UpdateSet; -import org.apache.ibatis.binding.MapperMethod; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; @@ -24,39 +23,25 @@ import org.apache.ibatis.plugin.Invocation; import org.apache.ibatis.plugin.Signature; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import java.lang.reflect.Field; -import java.text.SimpleDateFormat; -import java.time.format.DateTimeFormatter; -import java.util.Collection; import java.util.Collections; -import java.util.Date; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** - * MyBatis 쿼리 인터셉터 - * - * 이 인터셉터는 MyBatis에서 실행되는 SQL 쿼리를 가로채서 상세 정보를 로깅합니다. - * 원본 SQL, 실제 실행될 SQL(파라미터 값 포함), 파라미터 값 등을 로그에 기록합니다. - * local과 dev 프로파일에서만 활성화됩니다. - * - * 주요 기능: - * - SQL 쿼리 로깅 - * - 파라미터 값 추출 및 로깅 - * - 쿼리 실행 시간 측정 - * - include SQL 내부 파라미터 처리 - * - 다양한 데이터 타입 지원 (String, Number, Boolean, Date, LocalDateTime 등) + * MyBatis Query Interceptor + * + * datasource-proxy와 함께 동작하여 상세한 쿼리 분석을 제공 + * - 동적 쿼리의 처리 과정 분석 + * - include된 복잡한 쿼리의 상세 정보 + * - 파라미터 바인딩 전후 비교 */ @Component -@Profile({"local", "dev"}) // local과 dev 프로파일에서만 활성화 +//@Profile({"local", "dev"}) // local과 dev 프로파일에서만 활성화 @Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) @@ -64,299 +49,105 @@ import java.util.stream.Collectors; @Slf4j public class MyBatisQueryInterceptor implements Interceptor { - /** - * 인터셉터를 대상 객체에 연결합니다. - * - * @param target 대상 객체 - * @return 프록시 객체 - */ - @Override - public Object plugin(Object target) { - return org.apache.ibatis.plugin.Plugin.wrap(target, this); - } - - /** - * 인터셉터 속성을 설정합니다. - * - * @param properties 속성 - */ - @Override - public void setProperties(java.util.Properties properties) { - // 필요한 경우 속성 설정 - } - - /** - * MyBatis 쿼리 실행을 가로채서 로깅하고 원래 쿼리를 실행합니다. - * include SQL 내부의 파라미터도 올바르게 처리합니다. - * - * @param invocation MyBatis 인터셉터 호출 정보 - * @return 쿼리 실행 결과 - * @throws Throwable 쿼리 실행 중 발생한 예외 - */ @Override public Object intercept(Invocation invocation) throws Throwable { - // 로깅 레벨 확인으로 불필요한 처리 방지 - boolean isDebugEnabled = log.isDebugEnabled(); - boolean isInfoEnabled = log.isInfoEnabled(); - - if (!isInfoEnabled && !isDebugEnabled) { - // 로깅이 비활성화된 경우 바로 원래 쿼리 실행 - return invocation.proceed(); - } - - try { - MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; - Object parameter = invocation.getArgs()[1]; - - // 쿼리 정보 추출 - BoundSql boundSql = ms.getBoundSql(parameter); - String sql = boundSql.getSql(); - List parameterMappings = boundSql.getParameterMappings(); - - // 실제 실행될 쿼리 추출 (MyBatis 내부에서 처리된 결과) - String actualSql = getActualSql(boundSql, parameter); - - // 파라미터 정보 추출 - Map paramMap = extractDetailedParameters(parameter, parameterMappings); - - // 쿼리 정보 로깅 - logDetailedQueryInfo(ms.getId(), sql, actualSql, paramMap); - - // 원래 쿼리 실행 및 실행 시간 측정 - long startTime = System.currentTimeMillis(); - Object result = invocation.proceed(); - long endTime = System.currentTimeMillis(); - - // 쿼리 실행 시간 로깅 - if (isDebugEnabled) { - log.debug("쿼리 실행 시간: {} ms", (endTime - startTime)); - } - - return result; - } catch (Exception e) { - // 인터셉터 내부 오류는 로깅하고 원래 쿼리 실행 (인터셉터 오류로 쿼리가 실패하지 않도록) - log.warn("쿼리 인터셉터 처리 중 오류 발생: {}", e.getMessage()); - if (isDebugEnabled) { - log.debug("인터셉터 오류 상세 정보:", e); - } - return invocation.proceed(); - } + MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; + Object parameter = invocation.getArgs()[1]; + + BoundSql boundSql = ms.getBoundSql(parameter); + String sql = boundSql.getSql(); + List parameterMappings = boundSql.getParameterMappings(); + + // 실제 실행될 쿼리 추출 (MyBatis 내부에서 처리된 결과) + String actualSql = getActualSql(boundSql, parameter); + + // 파라미터 정보 추출 + Map paramMap = extractDetailedParameters(parameter, parameterMappings); + + logDetailedQueryInfo(ms.getId(), sql, actualSql, paramMap); + + // 원래 쿼리 실행 + long startTime = System.currentTimeMillis(); + Object result = invocation.proceed(); + long endTime = System.currentTimeMillis(); + + // 쿼리 실행 시간 로깅 + log.debug("Query execution time: {} ms", (endTime - startTime)); + + return result; } -/** - * 실제 실행될 SQL 쿼리를 생성합니다. - * 원본 SQL의 '?' 플레이스홀더를 실제 파라미터 값으로 대체합니다. - * include SQL 내부의 파라미터도 올바르게 처리합니다. - * foreach 구문에서 컬렉션이 비어있거나 null인 경우 특별 처리합니다. - */ private String getActualSql(BoundSql boundSql, Object parameter) { String sql = boundSql.getSql(); - List parameterMappings = boundSql.getParameterMappings(); - - if (parameterMappings == null || parameterMappings.isEmpty()) { + + if (parameter == null) { return sql; } - - // SQL에서 실제 파라미터 바인딩 위치 확인 - Pattern pattern = Pattern.compile("\\?"); - Matcher matcher = pattern.matcher(sql); - StringBuffer result = new StringBuffer(); - int paramIndex = 0; - - // IN 절 패턴 (대소문자 구분 없이) - Pattern inClausePattern = Pattern.compile("\\s+IN\\s*\\(\\s*\\?\\s*\\)", Pattern.CASE_INSENSITIVE); - Matcher inClauseMatcher = inClausePattern.matcher(sql); - Map inClausePositions = new HashMap<>(); - - // IN 절 위치 찾기 - while (inClauseMatcher.find()) { - // 해당 위치의 ? 인덱스 찾기 - String subSql = sql.substring(0, inClauseMatcher.end()); - int questionMarkCount = 0; - for (int i = 0; i < subSql.length(); i++) { - if (subSql.charAt(i) == '?') { - questionMarkCount++; - } - } - // 인덱스는 0부터 시작하므로 -1 - inClausePositions.put(questionMarkCount - 1, true); + + List parameterMappings = boundSql.getParameterMappings(); + + if (parameterMappings.isEmpty()) { + return sql; } - - while (matcher.find() && paramIndex < parameterMappings.size()) { - ParameterMapping parameterMapping = parameterMappings.get(paramIndex); - Object value = null; - - try { - // include SQL 내부의 파라미터 처리 개선 - if (parameter instanceof MapperMethod.ParamMap) { - MapperMethod.ParamMap paramMap = (MapperMethod.ParamMap) parameter; - String propertyName = parameterMapping.getProperty(); - - // 직접 키로 접근 - if (paramMap.containsKey(propertyName)) { - value = paramMap.get(propertyName); - } - // param1, param2 등으로 접근 - else if (propertyName.matches("param\\d+")) { - value = paramMap.get(propertyName); - } - // 중첩 객체 내부 필드 접근 - else if (propertyName.contains(".")) { - value = getNestedParameterValue(paramMap, propertyName); - } - // VO 객체 내부 필드 접근 - else { - for (Map.Entry entry : paramMap.entrySet()) { - if (entry.getValue() != null) { - Object nestedValue = getParameterValue(entry.getValue(), propertyName); - if (nestedValue != null) { - value = nestedValue; - break; - } - } - } - } - } else { - value = getParameterValue(parameter, parameterMapping.getProperty()); - } - - // IN 절 특별 처리 - boolean isInClause = inClausePositions.containsKey(paramIndex); - - // IN 절이고 컬렉션이 비어있거나 null인 경우 특별 처리 - if (isInClause) { - if (value == null) { - // IN (NULL) 대신 IN (0)으로 대체 (항상 false가 되도록) - matcher.appendReplacement(result, "0"); - log.debug("IN 절 NULL 파라미터 감지: 0으로 대체함 (파라미터: {})", parameterMapping.getProperty()); - } else if (value instanceof Collection && ((Collection) value).isEmpty()) { - // 빈 컬렉션인 경우 IN (0)으로 대체 - matcher.appendReplacement(result, "0"); - log.debug("IN 절 빈 컬렉션 감지: 0으로 대체함 (파라미터: {})", parameterMapping.getProperty()); - } else if (value.getClass().isArray() && java.lang.reflect.Array.getLength(value) == 0) { - // 빈 배열인 경우 IN (0)으로 대체 - matcher.appendReplacement(result, "0"); - log.debug("IN 절 빈 배열 감지: 0으로 대체함 (파라미터: {})", parameterMapping.getProperty()); - } else { - // 정상적인 컬렉션/배열인 경우 일반 처리 - String replacement = formatParameterValue(value); - matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); - } + + // SQL 복사본 생성 + String actualSql = sql; + + try { + // 파라미터 값 추출 + for (ParameterMapping parameterMapping : parameterMappings) { + String propertyName = parameterMapping.getProperty(); + Object value; + + if (boundSql.hasAdditionalParameter(propertyName)) { + value = boundSql.getAdditionalParameter(propertyName); + } else if (parameter instanceof Map) { + value = ((Map) parameter).get(propertyName); + } else if (parameter instanceof String) { + // String 타입 파라미터 처리 + value = parameter; + } else if (parameter instanceof Number) { + // Number 타입 파라미터 처리 (Integer, Long, Double 등) + value = parameter; + } else if (parameter instanceof Boolean) { + // Boolean 타입 파라미터 처리 + value = parameter; } else { - // 일반 파라미터 처리 - String replacement = formatParameterValue(value); - matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + value = getParameterValue(parameter, propertyName); } - - } catch (Exception e) { - log.debug("파라미터 값 추출 실패: {}, 기본값 사용", parameterMapping.getProperty()); - matcher.appendReplacement(result, "?"); - } - - paramIndex++; - } - - matcher.appendTail(result); - return result.toString(); -} -/** - * 중첩된 파라미터 값을 추출합니다. - */ -private Object getNestedParameterValue(MapperMethod.ParamMap paramMap, String propertyPath) { - String[] parts = propertyPath.split("\\."); - Object current = null; - - // 첫 번째 키로 객체 찾기 - for (Map.Entry entry : paramMap.entrySet()) { - if (entry.getKey().equals(parts[0]) || - (entry.getValue() != null && entry.getValue().getClass().getSimpleName().toLowerCase().contains(parts[0].toLowerCase()))) { - current = entry.getValue(); - break; - } - } - - // 중첩 필드 접근 - for (int i = 1; i < parts.length && current != null; i++) { - current = getParameterValue(current, parts[i]); - } - - return current; -} + String valueStr = value != null ? value.toString() : "null"; + // SQL 인젝션 방지를 위한 문자열 이스케이프 처리 + valueStr = valueStr.replace("'", "''"); -/** - * 파라미터 값을 SQL용 문자열로 포맷팅합니다. - * 컬렉션이 비어있거나 null인 경우 특별 처리합니다. - */ -private String formatParameterValue(Object value) { - if (value == null) { - return "NULL"; - } - - // 컬렉션 타입 처리 (List, Set, Array 등) - if (value instanceof Collection) { - Collection collection = (Collection) value; - if (collection.isEmpty()) { - // 빈 컬렉션인 경우 '0'을 반환하여 IN (0)으로 만듦 - // 이는 항상 false가 되어 결과가 없음을 보장함 (어떤 ID도 0과 일치하지 않음) - return "0"; - } - - // 컬렉션 요소를 쉼표로 구분된 문자열로 변환 - return collection.stream() - .map(this::formatParameterValue) - .collect(Collectors.joining(", ")); - } else if (value.getClass().isArray()) { - // 배열 타입 처리 - Object[] array = convertToObjectArray(value); - if (array.length == 0) { - // 빈 배열인 경우 '0'을 반환 - return "0"; - } - - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < array.length; i++) { - if (i > 0) { - sb.append(", "); + // 다양한 데이터 타입에 대한 처리 + if (value instanceof String) { + // 문자열 타입은 따옴표로 감싸기 + actualSql = actualSql.replaceFirst("\\?", "'" + valueStr + "'"); + } else if (value instanceof Number) { + // 숫자 타입 (Integer, Long, Double 등)은 그대로 사용 + actualSql = actualSql.replaceFirst("\\?", valueStr); + } else if (value instanceof Boolean) { + // Boolean 타입은 그대로 사용 + actualSql = actualSql.replaceFirst("\\?", valueStr); + } else if (value instanceof java.util.Date) { + // 표준 타임스탬프 포맷 사용 + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + actualSql = actualSql.replaceFirst("\\?", "'" + sdf.format(value) + "'"); + } else if (value instanceof java.time.LocalDateTime) { + // Java 8 LocalDateTime 처리 + actualSql = actualSql.replaceFirst("\\?", "'" + value.toString().replace('T', ' ') + "'"); + } else { + // 기타 타입은 null이 아닌 경우 따옴표로 감싸기 + actualSql = actualSql.replaceFirst("\\?", value != null ? "'" + valueStr + "'" : valueStr); } - sb.append(formatParameterValue(array[i])); } - return sb.toString(); - } - - if (value instanceof String) { - return "'" + value.toString().replace("'", "''") + "'"; - } else if (value instanceof Number || value instanceof Boolean) { - return value.toString(); - } else if (value instanceof Date) { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - return "'" + sdf.format((Date) value) + "'"; - } else if (value instanceof java.time.LocalDateTime) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - return "'" + ((java.time.LocalDateTime) value).format(formatter) + "'"; - } else if (value instanceof java.time.LocalDate) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - return "'" + ((java.time.LocalDate) value).format(formatter) + "'"; - } else { - return "'" + value.toString().replace("'", "''") + "'"; + } catch (Exception e) { + log.warn("Failed to get actual SQL with parameters", e); + return sql; } -} -/** - * 배열 객체를 Object[] 타입으로 변환합니다. - */ -private Object[] convertToObjectArray(Object array) { - if (array instanceof Object[]) { - return (Object[]) array; - } - - // 기본 타입 배열 처리 - int length = java.lang.reflect.Array.getLength(array); - Object[] result = new Object[length]; - for (int i = 0; i < length; i++) { - result[i] = java.lang.reflect.Array.get(array, i); - } - return result; + return actualSql; } private Object getParameterValue(Object parameter, String propertyName) { @@ -380,308 +171,113 @@ private Object getParameterValue(Object parameter, String propertyName) { } } - /** - * 파라미터 객체에서 상세 정보를 추출하여 맵으로 반환합니다. - * include SQL 내부의 파라미터도 올바르게 추출합니다. - */ private Map extractDetailedParameters(Object parameter, List parameterMappings) { - Map paramMap = new LinkedHashMap<>(); - - if (parameter == null || parameterMappings == null || parameterMappings.isEmpty()) { + Map paramMap = new HashMap<>(); + + if (parameter == null) { return paramMap; } - - // 실제 사용되는 파라미터만 추출 - for (ParameterMapping parameterMapping : parameterMappings) { - String propertyName = parameterMapping.getProperty(); - Object value = null; - - try { - if (parameter instanceof MapperMethod.ParamMap) { - MapperMethod.ParamMap mapperParamMap = (MapperMethod.ParamMap) parameter; - - // 1. 직접 키로 접근 - if (mapperParamMap.containsKey(propertyName)) { - value = mapperParamMap.get(propertyName); - } - // 2. 중첩 객체 내부 필드 접근 (include SQL에서 자주 발생) - else if (propertyName.contains(".")) { - value = getNestedParameterValue(mapperParamMap, propertyName); - } - // 3. VO 객체 내부 검색 - else { - for (Map.Entry entry : mapperParamMap.entrySet()) { - if (entry.getValue() != null) { - Object nestedValue = getParameterValue(entry.getValue(), propertyName); - if (nestedValue != null) { - value = nestedValue; - break; - } - } - } - } - - // 4. 전체 맵 내용도 포함 (디버깅용) - paramMap.putAll(mapperParamMap); - } else { - value = getParameterValue(parameter, propertyName); - extractObjectFields(parameter, paramMap); - } - - if (value != null) { - paramMap.put(propertyName, value); - } - - } catch (Exception e) { - log.debug("파라미터 추출 실패: {} - {}", propertyName, e.getMessage()); - } + + if (parameter instanceof Map) { + paramMap.putAll((Map) parameter); + } else { + // 객체의 필드 정보도 추출 + paramMap.put("param", parameter); + extractObjectFields(parameter, paramMap); } - + return paramMap; } - /** - * 객체의 모든 필드를 추출하여 맵에 추가합니다. - * include SQL 내부의 파라미터도 올바르게 추출합니다. - * - * @param obj 필드를 추출할 객체 - * @param paramMap 추출된 필드를 저장할 맵 - */ private void extractObjectFields(Object obj, Map paramMap) { - if (obj == null) { - return; - } - - // 기본 타입이나 래퍼 타입은 처리하지 않음 - if (obj instanceof String || obj instanceof Number || obj instanceof Boolean || - obj instanceof java.util.Date || obj instanceof java.time.temporal.Temporal) { - return; - } - try { Class currentClass = obj.getClass(); - - // 시스템 클래스는 처리하지 않음 - if (currentClass.getName().startsWith("java.") || - currentClass.getName().startsWith("javax.") || - currentClass.getName().startsWith("sun.")) { - return; - } - - // 현재 클래스부터 상위 클래스까지 순회하면서 필드 추출 - while (currentClass != null && !currentClass.getName().startsWith("java.")) { + while (currentClass != null) { Field[] fields = currentClass.getDeclaredFields(); for (Field field : fields) { try { - // static 또는 transient 필드는 건너뛰기 - if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) || - java.lang.reflect.Modifier.isTransient(field.getModifiers())) { + // String 타입의 객체는 건너뛰기 + if (obj instanceof String) { continue; } - + + // 시스템 클래스의 필드는 건너뛰기 + if (field.getDeclaringClass().getName().startsWith("java.")) { + continue; + } + field.setAccessible(true); Object value = field.get(obj); - - // 값이 null이 아닌 경우만 맵에 추가 - if (value != null) { - String fieldName = field.getName(); - paramMap.put(fieldName, value); - - // MANAGER_CD 특별 처리 (include SQL 내부에서도 적용) - if (fieldName.equalsIgnoreCase("currentUserId")) { - paramMap.put("MANAGER_CD", value); - paramMap.put("manager_cd", value); - } - - // 중첩된 객체의 필드도 추출 (깊이 1까지만) - if (!(value instanceof Map) && - !(value instanceof Collection) && - !(value instanceof String) && - !(value instanceof Number) && - !(value instanceof Boolean) && - !value.getClass().getName().startsWith("java.")) { - - // 중첩된 객체의 필드를 추출하여 필드명.중첩필드명 형태로 저장 - Class nestedClass = value.getClass(); - Field[] nestedFields = nestedClass.getDeclaredFields(); - for (Field nestedField : nestedFields) { - try { - if (java.lang.reflect.Modifier.isStatic(nestedField.getModifiers()) || - java.lang.reflect.Modifier.isTransient(nestedField.getModifiers())) { - continue; - } - - nestedField.setAccessible(true); - Object nestedValue = nestedField.get(value); - if (nestedValue != null) { - String nestedFieldName = nestedField.getName(); - paramMap.put(nestedFieldName, nestedValue); - } - } catch (IllegalAccessException | SecurityException e) { - // 중첩 필드 접근 실패는 무시하고 계속 진행 - } - } - } - } + paramMap.put(field.getName(), value); + } catch (IllegalAccessException | SecurityException e) { // 개별 필드 접근 실패는 무시하고 계속 진행 - if (log.isDebugEnabled()) { - log.debug("필드 접근 실패: {} ({})", field.getName(), e.getMessage()); - } + log.debug("필드 접근 실패: {} ({})", field.getName(), e.getMessage()); } } currentClass = currentClass.getSuperclass(); } } catch (Exception e) { - log.warn("객체 필드 추출 중 오류 발생: {}", e.getMessage()); - if (log.isDebugEnabled()) { - log.debug("상세 오류 정보:", e); - } + log.warn("객체 필드 추출 실패", e); } } - /** - * 쿼리 실행 정보를 상세하게 로깅합니다. - * - * @param mapperMethod 매퍼 메서드 이름 - * @param originalSql 원본 SQL 쿼리 - * @param actualSql 실제 실행될 SQL 쿼리 (파라미터 값이 포함된) - * @param paramMap 파라미터 정보가 담긴 맵 - */ private void logDetailedQueryInfo(String mapperMethod, String originalSql, String actualSql, Map paramMap) { - // 로깅 레벨 확인으로 불필요한 문자열 연산 방지 - if (!log.isInfoEnabled()) { - return; - } - StringBuilder logMessage = new StringBuilder(); logMessage.append("\n"); - logMessage.append("┌─────────────── MyBatis 쿼리 상세 정보 ───────────────\n"); - logMessage.append("│ 매퍼 메서드: ").append(mapperMethod).append("\n"); - - // 파라미터 정보 로깅 (너무 길면 축약) - String paramString = paramMap.toString(); - if (paramString.length() > 1000) { - paramString = paramString.substring(0, 997) + "..."; - } - logMessage.append("│ 파라미터: ").append(paramString).append("\n"); - - // 실제 실행 SQL 포맷팅 (local, dev 환경에서만) - logMessage.append("│ 실행 SQL:\n"); + logMessage.append("┌─────────────── MyBatis Query Details ───────────────\n"); + logMessage.append("│ Mapper Method: ").append(mapperMethod).append("\n"); + logMessage.append("│ Parameters: ").append(paramMap).append("\n"); + + // 원본 SQL 포맷팅, prd, 운영 + //logMessage.append("│ Original SQL:\n"); + //formatSqlInLog(logMessage, originalSql); + + // 실제 실행 SQL 포맷팅, local, dev 로컬 개발 + logMessage.append("│ Actual SQL:\n"); formatSqlInLog(logMessage, actualSql); - + logMessage.append("└──────────────────────────────────────────────────────"); - + log.info(logMessage.toString()); } - - /** - * SQL 쿼리를 로그 메시지에 포맷팅하여 추가합니다. - * - * @param logMessage 로그 메시지 빌더 - * @param sql 포맷팅할 SQL 쿼리 - */ + private void formatSqlInLog(StringBuilder logMessage, String sql) { - try { - // SQL 키워드 하이라이트 및 들여쓰기 - String formattedSql = formatSql(sql); - String[] lines = formattedSql.split("\n"); + // SQL 키워드 하이라이트 및 들여쓰기 + String formattedSql = formatSql(sql); + String[] lines = formattedSql.split("\n"); - for (String line : lines) { - logMessage.append("│ ").append(line).append("\n"); - } - } catch (Exception e) { - // 포맷팅 실패 시 원본 SQL 출력 - logMessage.append("│ ").append(sql).append("\n"); - log.debug("SQL 포맷팅 실패: {}", e.getMessage()); + for (String line : lines) { + //logMessage.append("│ ").append(line).append("\n"); + logMessage.append(line).append("\n"); } } -/** - * SQL 쿼리를 포맷팅하여 가독성을 높입니다. - * - * @param sql 포맷팅할 SQL 쿼리 - * @return 포맷팅된 SQL 쿼리 - */ private String formatSql(String sql) { - if (sql == null || sql.trim().isEmpty()) { - return ""; - } - try { // SQL 파서를 사용한 포맷팅 Statement statement = CCJSqlParserUtil.parse(sql); return formatStatement(statement, 0); } catch (JSQLParserException e) { + log.debug("SQL 파싱 실패. 기본 포맷팅으로 대체합니다.", e); + log.info("SQL 파싱 실패. 기본 포맷팅으로 대체합니다."); // 파싱 실패 시 기본 포맷팅 사용 - if (log.isDebugEnabled()) { - log.debug("SQL 파싱 실패. 기본 포맷팅으로 대체합니다: {}", e.getMessage()); - } - - // 기본 포맷팅: 키워드 대문자화 및 줄바꿈 추가 - String formattedSql = sql.replaceAll("\\s+", " ").trim(); - - // 주요 SQL 키워드 앞에 줄바꿈 추가 - formattedSql = formattedSql - .replaceAll("(?i)\\s+SELECT\\s+", "\nSELECT ") - .replaceAll("(?i)\\s+FROM\\s+", "\nFROM ") - .replaceAll("(?i)\\s+WHERE\\s+", "\nWHERE ") - .replaceAll("(?i)\\s+AND\\s+", "\n AND ") - .replaceAll("(?i)\\s+OR\\s+", "\n OR ") - .replaceAll("(?i)\\s+GROUP BY\\s+", "\nGROUP BY ") - .replaceAll("(?i)\\s+HAVING\\s+", "\nHAVING ") - .replaceAll("(?i)\\s+ORDER BY\\s+", "\nORDER BY ") - .replaceAll("(?i)\\s+LIMIT\\s+", "\nLIMIT ") - .replaceAll("(?i)\\s+OFFSET\\s+", "\nOFFSET ") - .replaceAll("(?i)\\s+UNION\\s+", "\nUNION\n") - .replaceAll("(?i)\\s+INSERT\\s+INTO\\s+", "\nINSERT INTO ") - .replaceAll("(?i)\\s+VALUES\\s+", "\nVALUES ") - .replaceAll("(?i)\\s+UPDATE\\s+", "\nUPDATE ") - .replaceAll("(?i)\\s+SET\\s+", "\nSET ") - .replaceAll("(?i)\\s+DELETE\\s+FROM\\s+", "\nDELETE FROM ") - .replaceAll("(?i)\\s+JOIN\\s+", "\nJOIN ") - .replaceAll("(?i)\\s+LEFT\\s+JOIN\\s+", "\nLEFT JOIN ") - .replaceAll("(?i)\\s+RIGHT\\s+JOIN\\s+", "\nRIGHT JOIN ") - .replaceAll("(?i)\\s+INNER\\s+JOIN\\s+", "\nINNER JOIN ") - .replaceAll("(?i)\\s+OUTER\\s+JOIN\\s+", "\nOUTER JOIN "); - - return formattedSql; + return sql.replaceAll("\\s+", " ").trim(); } } -/** - * SQL 문장을 유형에 따라 포맷팅합니다. - * - * @param statement SQL 문장 객체 - * @param indent 들여쓰기 수준 - * @return 포맷팅된 SQL 문자열 - */ private String formatStatement(Statement statement, int indent) { - if (statement == null) { - return ""; - } - StringBuilder result = new StringBuilder(); + String indentation = String.join("", Collections.nCopies(indent, " ")); - try { - if (statement instanceof Select) { - formatSelect((Select) statement, result, indent); - } else if (statement instanceof Insert) { - formatInsert((Insert) statement, result, indent); - } else if (statement instanceof Update) { - formatUpdate((Update) statement, result, indent); - } else if (statement instanceof Delete) { - formatDelete((Delete) statement, result, indent); - } else { - // 지원하지 않는 문장 유형 - result.append(statement.toString()); - } - } catch (Exception e) { - // 포맷팅 중 오류 발생 시 원본 문장 반환 - if (log.isDebugEnabled()) { - log.debug("SQL 문장 포맷팅 실패: {}", e.getMessage()); - } - result.append(statement.toString()); + if (statement instanceof Select) { + formatSelect((Select) statement, result, indent); + } else if (statement instanceof Insert) { + formatInsert((Insert) statement, result, indent); + } else if (statement instanceof Update) { + formatUpdate((Update) statement, result, indent); + } else if (statement instanceof Delete) { + formatDelete((Delete) statement, result, indent); } return result.toString(); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 1d0e347..f6fb33a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -47,6 +47,7 @@ logging: level: org.springframework: INFO go.kr.project: DEBUG + go.kr.project.sql.query: OFF egovframework: DEBUG org.mybatis: INFO diff --git a/src/main/webapp/WEB-INF/views/login/login.jsp b/src/main/webapp/WEB-INF/views/login/login.jsp index ebd1488..93a706c 100644 --- a/src/main/webapp/WEB-INF/views/login/login.jsp +++ b/src/main/webapp/WEB-INF/views/login/login.jsp @@ -6,7 +6,7 @@