해당 글에서는 MyBatis를 이용하기 위해 DB Fomratter로 p6spy 적용 방법 및 활용 방법에 대해 알아봅니다
1) p6spy-spring-boot-starter
💡 p6spy-spring-boot-starter
- SQL 쿼리를 실제 실행되는 완성된 형태(파라미터 바인딩 포함)로 로깅해 주는 라이브러리를 의미합니다. - JPA/MyBatis 환경에서 ?로 표시되는 파라미터 값을 실제 값으로 치환해서 보여줍니다. - MyBatis를 이용한 경우 log4jdbc-log4j2를 이용하였지만, 2013년 이후에 업데이트가 중단되었고 설정이 복잡하다는 점이 있었습니다. 그렇기에 p6spy-spring-boot-starter를 적용해 봅니다.
💡 [참고] 기존의 log4jdbc-log4j2에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.
💡 resources/spy.properties - 로그 포맷을 설정하고, 실행 시간을 지정합니다. 그리고 필터 내용에 대해서 지정이 가능합니다. - 또한, logMessageFormat를 통해서 커스텀 포맷터 클래스를 참조할 수 있지만, 기본만을 이용한다고 하면 customLogMessageFormat 속성값을 입력하면 기본만 적용이 가능합니다.
# 로그 포맷 설정
appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 커스텀 포멧터 클래스 참조(추가 커스텀 포멧을 이용하는 경우)
# logMessageFormat=package.name.config.P6SpyFormatter
# 실행 시간 ms 기준 (0이면 전부 출력)
executionThreshold=0
# 특정 카테고리만 로깅
filter=false
include=
exclude=
# 간단 버전을 한다면, P6SpyFormatter 구성없이 기본 설정으로 가능
customLogMessageFormat=%(executionTime)ms | %(sql)
💡 [참고] 기본 옵션만을 저장하는 경우
💡 [참고] logMessageFormat을 통해 적용하는 경우
- 아래와 같이 커스텀으로 실행 정보, 바인딩 파라미터, 호출위치, 실행 SQL을 확인할 수 있습니다.
5. (선택) P6SpyFormatter
💡 (선택) P6SpyFormatter
- P6Spy에서 제공하는 기본정보에 추가적으로 커스텀으로 정보를 추가하였습니다. - 실행 정보, 바인딩 파라미터, 호출위치, 실행 SQL을 확인할 수 있습니다.
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Stack;
/**
* P6Spy SQL 로그 포매터
* - SQL 타입 감지 및 아이콘 표시
* - 콜스택 기반 호출 위치 추적
* - 바인딩 파라미터 타입 추론
*
* @author : leejonghoon
* @fileName : P6SpyFormatter
* @since : 26. 3. 3.
*/
@Configuration
public class P6SpyFormatter implements MessageFormattingStrategy {
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final String BASE_PACKAGE = "com.daekyocns";
@Override
public String formatMessage(int connectionId, String now, long elapsed,
String category, String prepared, String sql, String url) {
if (sql == null || sql.isBlank()) return "";
String sqlType = detectSqlType(sql);
String callStack = buildCallStack();
List<String> params = extractParams(prepared, sql);
StringBuilder sb = new StringBuilder();
sb.append("\\n+==============================================================");
sb.append("\\n| SQL ").append(sqlType);
sb.append("\\n+==============================================================");
sb.append("\\n| 실행시각 ").append(LocalDateTime.now().format(DATE_FORMATTER));
sb.append("\\n| 실행시간 ").append(elapsed).append("ms");
sb.append("\\n| 커넥션 ID ").append(connectionId);
sb.append("\\n| 카테고리 ").append(category);
if (!params.isEmpty()) {
sb.append("\\n+--------------------------------------------------------------");
sb.append("\\n| 바인딩 파라미터");
for (int i = 0; i < params.size(); i++) {
sb.append("\\n| [").append(String.format("%02d", i + 1)).append("] ").append(params.get(i));
}
}
if (!callStack.isBlank()) {
sb.append("\\n+--------------------------------------------------------------");
sb.append("\\n| 호출 위치");
sb.append(callStack);
}
sb.append("\\n+--------------------------------------------------------------");
sb.append("\\n| 실행 SQL");
sb.append("\\n|");
for (String line : formatSql(sql).split("\\n")) {
sb.append("\\n ").append(line);
}
sb.append("\\n|");
sb.append("\\n+==============================================================\\n");
return sb.toString();
}
/**
* SQL 타입 감지
*
* @param sql
* @return
*/
private String detectSqlType(String sql) {
String upper = sql.trim().toUpperCase(Locale.ROOT);
if (upper.startsWith("SELECT")) return "SELECT";
if (upper.startsWith("INSERT")) return "INSERT";
if (upper.startsWith("UPDATE")) return "UPDATE";
if (upper.startsWith("DELETE")) return "DELETE";
if (upper.startsWith("CREATE")) return "DDL · CREATE";
if (upper.startsWith("ALTER")) return "DDL · ALTER";
if (upper.startsWith("DROP")) return "DDL · DROP";
if (upper.startsWith("TRUNCATE")) return "DDL · TRUNCATE";
return "SQL";
}
private String getSqlIcon(String sqlType) {
return switch (sqlType) {
case "SELECT" -> "🔍";
case "INSERT" -> "➕";
case "UPDATE" -> "✏️";
case "DELETE" -> "🗑️";
default -> "⚙️";
};
}
/**
* 콜스택 — 프로젝트 내부 호출 위치만 추출
*
* @return
*/
private String buildCallStack() {
StackTraceElement[] trace = new Throwable().getStackTrace();
Stack<String> stack = new Stack<>();
for (StackTraceElement el : trace) {
String s = el.toString();
if (s.startsWith(BASE_PACKAGE) && !s.contains("P6SpyFormatter")) {
stack.push(s);
}
}
if (stack.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
int order = 1;
while (!stack.isEmpty()) {
sb.append("\\n| ").append(order++).append(". ").append(stack.pop());
}
return sb.toString();
}
/**
* 파라미터 추출
*
* @param prepared
* @param sql
* @return
*/
private List<String> extractParams(String prepared, String sql) {
List<String> params = new ArrayList<>();
if (prepared == null || !prepared.contains("?")) return params;
String[] parts = prepared.split("\\\\?", -1);
String remaining = sql;
for (int i = 0; i < parts.length - 1; i++) {
String prefix = parts[i];
if (remaining.startsWith(prefix)) {
remaining = remaining.substring(prefix.length());
} else {
break;
}
String nextPrefix = (i + 1 < parts.length) ? parts[i + 1] : "";
String value;
if (nextPrefix.isEmpty()) {
value = remaining;
} else {
int idx = remaining.indexOf(nextPrefix);
if (idx == -1) break;
value = remaining.substring(0, idx);
remaining = remaining.substring(idx);
}
params.add(inferType(value.trim()));
}
return params;
}
private String inferType(String v) {
if (v.equals("null")) return v + " [NULL]";
if (v.matches("-?\\\\d+")) return v + " [Integer]";
if (v.matches("-?\\\\d+\\\\.\\\\d+")) return v + " [Double]";
if (v.matches("'\\\\d{4}-\\\\d{2}-\\\\d{2}.*'")) return v + " [DateTime]";
if (v.equalsIgnoreCase("true")
|| v.equalsIgnoreCase("false")) return v + " [Boolean]";
return v + " [String]";
}
/**
* SQL 포맷
*
* @param sql
* @return
*/
private String formatSql(String sql) {
return sql.trim()
.replaceAll("\\\\s+", " ")
.replaceAll("(?i)\\\\b(SELECT|FROM|WHERE|AND|OR|"
+ "LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|CROSS JOIN|JOIN|"
+ "GROUP BY|ORDER BY|HAVING|LIMIT|OFFSET|"
+ "INSERT INTO|VALUES|UPDATE|SET|DELETE FROM|"
+ "CREATE TABLE|ALTER TABLE|DROP TABLE)\\\\b",
"\\n $1")
.trim();
}
}