일지 03
실행된 SQL을 붙잡다
MyBatis가 실제로 만든 SQL과 바인드 값을 날짜별 전용 로그에 남겨 신규 Next.js API의 검증 자료로 사용한다.
VS Code와 폐쇄망 로컬 AI 환경을 맞춘 뒤, 정시스템 주무관이 거대한 MyBatis XML 파일을 열었다.
조건문은 한 화면 높이로 끝나지 않았다. 프로젝트 상태, 담당 조직, 외주업체, 계약 기간, 지급 단계에 따라 <if>, <choose>, <foreach>가 서로 다른 SQL을 만들었다.
이코딩 주무관이 물었다.
“이 XML을 위에서부터 읽어서 Knex로 옮기면 됩니까?”
박개발 사무관이 고개를 저었다.
“XML에 적힌 SQL과 실제 사용자가 실행한 SQL은 다를 수 있습니다. 조건이 어떤 조합으로 들어오는지부터 알아야 합니다.”
김전산 주무관이 말했다.
“추측해서 옮기지 맙시다. 기존 MyBatis가 개발계와 테스트베드에서 실제로 만든 SQL을 로그로 붙잡고, 그 자료로 신규 Next.js API를 작성합시다.”
세 가지를 함께 남긴다
팀은 SQL 한 건마다 다음 세 가지를 기록하기로 했다.
- MyBatis Mapper 식별자
- 바인딩 전 SQL과 타입이 있는 파라미터
- 사람이 읽기 위한 바인딩 후 진단 SQL
예를 들어 MyBatis가 JDBC에 넘기기 전의 SQL은 다음과 같다.
SELECT PROJECT_ID,
PROJECT_NAME,
STATUS
FROM TB_PROJECT
WHERE STATUS = ?
AND COMPANY_ID = ?
ORDER BY CREATED_AT DESC
파라미터는 SQL 문자열과 분리해서 남긴다.
1: status=ACTIVE (VARCHAR)
2: companyId=C0017 (VARCHAR)
진단용으로 렌더링한 SQL은 다음처럼 보인다.
SELECT PROJECT_ID,
PROJECT_NAME,
STATUS
FROM TB_PROJECT
WHERE STATUS = 'ACTIVE'
AND COMPANY_ID = 'C0017'
ORDER BY CREATED_AT DESC
정시스템 주무관이 “진단용”이라는 말에 밑줄을 그었다.
“JDBC 드라이버가 데이터베이스에서 실행한 최종 문자열을 그대로 돌려주는 것은 아닙니다. 실제 기준 자료는 ?가 있는 SQL, 파라미터 순서, JDBC 타입입니다. 바인딩 후 SQL은 사람이 읽고 비교하기 좋게 우리가 만든 표현입니다.”
물음표를 단순히 앞에서부터 문자열로 바꾸는 구현은 사용하지 않는다. 문자열 안의 물음표, NULL, 날짜, 숫자, 따옴표, 배열과 Oracle 타입을 잘못 처리할 수 있기 때문이다. MyBatis의 BoundSql과 ParameterMapping 순서를 이용해 타입별로 렌더링한다.
MyBatis가 Log4j2를 사용하게 한다
기존 프로젝트의 MyBatis 설정에서 로그 구현을 확인한다.
<configuration>
<settings>
<setting name="logImpl" value="LOG4J2"/>
</settings>
</configuration>
Spring 설정으로 MyBatis를 구성한 프로젝트라면 기존 SqlSessionFactory 설정에서 같은 항목이 실제로 적용되는지 확인한다. 이미 SLF4J를 거쳐 Log4j2로 보내는 구성이라면 중복 로거를 추가하지 않는다.
단순히 전체 애플리케이션 로그를 DEBUG로 올리면 세션, 프레임워크 내부 메시지와 SQL이 뒤섞인다. SQL 수집 전용 로거 이름을 정한다.
egov.sql.capture
MyBatis 인터셉터는 이 로거에만 구조화된 한 줄을 기록한다.
requestId=7f31...
mapper=com.thinktree.project.ProjectMapper.selectProjects
rawSql=SELECT ... WHERE STATUS = ? AND COMPANY_ID = ?
params=[{"index":1,"name":"status","jdbcType":"VARCHAR","value":"ACTIVE"}, ...]
renderedSql=SELECT ... WHERE STATUS = 'ACTIVE' AND COMPANY_ID = 'C0017'
elapsedMs=38
rowCount=20
Mapper ID와 요청 ID를 함께 남겨야 어느 화면과 업무에서 나온 SQL인지 추적할 수 있다. 실행 시간과 결과 건수는 신규 API의 성능 비교 기준이 된다.
sql.log.날짜로 분리하다
정운영 주무관은 기존 Log4j2 설정에 SQL 전용 RollingFile Appender를 추가했다.
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Properties>
<Property name="LOG_DIR">/var/log/egov</Property>
</Properties>
<Appenders>
<RollingFile
name="SqlRollingFile"
fileName="${LOG_DIR}/sql.log"
filePattern="${LOG_DIR}/sql.log.%d{yyyy-MM-dd}">
<PatternLayout
charset="UTF-8"
pattern="%d{yyyy-MM-dd'T'HH:mm:ss.SSS} %-5level %msg%n"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
</Policies>
<DefaultRolloverStrategy max="30"/>
</RollingFile>
</Appenders>
<Loggers>
<Logger name="egov.sql.capture"
level="debug"
additivity="false">
<AppenderRef ref="SqlRollingFile"/>
</Logger>
</Loggers>
</Configuration>
현재 날짜의 로그는 sql.log에 기록된다. 자정이 지나면 다음처럼 날짜가 붙은 파일로 분리된다.
/var/log/egov/sql.log
/var/log/egov/sql.log.2026-10-12
/var/log/egov/sql.log.2026-10-13
파일명을 처음부터 sql.log.2026-10-12로 열어 두는 것보다 활성 파일은 sql.log, 완료된 날짜는 sql.log.날짜로 롤링하는 구성이 운영 도구와 로그 수집기에 더 안정적이다.
프로젝트가 구형 Log4j 1.x를 사용한다면 먼저 버전과 보안 지원 상태를 확인한다. 바로 Log4j2로 올릴 수 없는 경우에만 임시로 DailyRollingFileAppender를 사용할 수 있다.
log4j.appender.SQL=org.apache.log4j.DailyRollingFileAppender
log4j.appender.SQL.File=/var/log/egov/sql.log
log4j.appender.SQL.DatePattern='.'yyyy-MM-dd
log4j.appender.SQL.layout=org.apache.log4j.PatternLayout
log4j.appender.SQL.layout.ConversionPattern=%d{ISO8601} %-5p %m%n
log4j.logger.egov.sql.capture=DEBUG, SQL
log4j.additivity.egov.sql.capture=false
Log4j 1.x 설정은 장기 목표가 아니다. SQL 수집 때문에 지원이 끝난 로깅 라이브러리를 계속 유지해서는 안 된다.
바인딩 전과 후를 만드는 인터셉터
박개발 사무관은 Mapper XML을 고치는 대신 MyBatis 플러그인으로 SQL 수집 지점을 하나로 모았다.
@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
}
)
})
public final class SqlCaptureInterceptor implements Interceptor {
private static final Logger SQL_LOG =
LogManager.getLogger("egov.sql.capture");
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement statement =
(MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = statement.getBoundSql(parameter);
SqlCapture capture = SqlCapture.from(
statement.getId(),
boundSql,
statement.getConfiguration(),
parameter
);
long started = System.nanoTime();
try {
Object result = invocation.proceed();
SQL_LOG.debug(capture.withResult(
elapsedMillis(started),
resultCount(result)
).toJson());
return result;
} catch (Throwable error) {
SQL_LOG.error(capture.withError(
elapsedMillis(started),
error.getClass().getName()
).toJson());
throw error;
}
}
}
예제는 구조를 보여 주는 골격이다. 실제 구현에서는 Executor.query의 다른 오버로드, 추가 파라미터, 동적 SQL과 배치 실행도 검사해야 한다.
SqlCapture.from은 다음 정보를 사용한다.
MappedStatement.getId()BoundSql.getSql()BoundSql.getParameterMappings()- 입력 파라미터 객체
- MyBatis TypeHandler와 JDBC 타입
- 동적 SQL이 만든 추가 파라미터
문자열은 작은따옴표를 이스케이프하고, 날짜는 Oracle 세션 설정에 기대지 않는 진단 형식으로 표시한다. CLOB, BLOB, 파일과 긴 문자열은 전체 값을 남기지 않고 타입, 길이와 해시만 남긴다.
attachment = <BLOB length=428103 sha256=...>
description = <CLOB length=8124 sha256=...>
SQL 로그에는 업무 비밀이 들어간다
한 줄의 SQL 로그에는 사용자 이름, 업체 정보, 계약 금액과 인력 단가가 포함될 수 있다. 폐쇄망이라고 해서 아무 값이나 남겨도 되는 것은 아니다.
팀은 다음 원칙을 정했다.
- 운영계에서는 기본적으로 바인딩 값을 남기지 않는다.
- 상세 SQL 수집은 개발계와 테스트베드에서만 승인된 기간 동안 켠다.
- 비밀번호, 토큰, 주민번호, 계좌번호와 첨부 본문은 마스킹한다.
- 로그 디렉터리는 애플리케이션 계정과 승인된 분석자만 읽는다.
- 전송·백업·보존·파기 기간을 정한다.
- 오류 메시지에도 전체 파라미터가 다시 찍히지 않게 한다.
- 비교 자료를 만들고 나면 원본 로그를 승인된 절차에 따라 폐기한다.
Log4j 설정 파일의 권한도 일반 개발자가 운영 중 임의로 바꿀 수 없게 한다. 수집 시작과 종료 시각, 요청자, 승인자와 대상 기능을 기록한다.
로그를 신규 API 설계 자료로 바꾸다
정시스템 주무관은 sql.log.날짜를 그대로 신규 코드에 복사하지 않았다. 먼저 Mapper별로 묶었다.
ProjectMapper.selectProjects
├─ 호출 횟수
├─ 실제 사용된 조건 조합
├─ 대표 rawSql
├─ 파라미터 타입과 예제
├─ 평균·최대 elapsedMs
├─ 평균·최대 rowCount
└─ 호출 화면과 사용자 역할
그 결과 사용되지 않는 조건과 자주 실행되는 조건이 구분됐다. 같은 Mapper ID에서도 동적 SQL 모양이 여러 개 나왔고, 월말에만 실행되는 원가·지급 조건도 발견됐다.
신규 Next.js API에는 로그 문자열을 실행하는 기능을 만들지 않는다. 수집 결과를 다음 자료로 변환한다.
- OpenAPI의 검색 입력과 응답 필드
- Knex Query Builder의 조건 조합
- 바인드된 Oracle SQL이 필요한 복잡한 집계
- MyBatis·Next.js 결과 비교 테스트
- 권한별 대표 입력 사례
- 성능 기준선과 회귀 테스트
이코딩 주무관이 물었다.
“로그에 한 번도 나오지 않은 조건은 지워도 됩니까?”
“아닙니다.” 박개발 사무관이 답했다. “로그는 관찰한 기간의 사실이지 전체 업무 명세가 아닙니다. 계절 업무, 연말 정산, 장애 복구 기능은 그 기간에 호출되지 않았을 수 있습니다. 소스, 업무 문서와 담당자 확인을 함께 봐야 합니다.”
MyBatis의 <if>를 Knex로 옮기다
이코딩 주무관이 MyBatis XML의 조건문을 가리켰다.
<if test="status != null">
AND STATUS = #{status}
</if>
<if test="companyId != null">
AND COMPANY_ID = #{companyId}
</if>
“MyBatis는 <if test>로 SQL 일부를 넣었다 뺄 수 있습니다. Knex도 sql.select(...).if(조건).where(...) 같은 방식이 있습니까?”
정시스템 주무관이 답했다.
“Knex 기본 API에는 .if()가 없습니다. 대신 JavaScript 조건문이나 공식 .modify()를 사용합니다. 하는 일은 같습니다.”
const query = knex("TB_PROJECT")
.select("PROJECT_ID", "PROJECT_NAME", "STATUS")
.modify((qb) => {
if (status) {
qb.where("STATUS", status);
}
if (companyId) {
qb.where("COMPANY_ID", companyId);
}
if (keyword) {
qb.whereLike("PROJECT_NAME", `%${keyword}%`);
}
})
.orderBy("CREATED_AT", "desc");
값은 문자열 SQL에 붙이지 않고 Knex가 바인딩하도록 둔다. 조건이 없으면 해당 where가 추가되지 않는다.
MyBatis 동적 태그는 다음 방식으로 대응할 수 있다.
| MyBatis | Knex.js |
|---|---|
<if test> |
JavaScript if 또는 .modify() |
<choose>/<when>/<otherwise> |
if / else if / else |
<foreach> |
.whereIn(), 필요한 경우 반복 조합 |
<where> |
Query Builder가 WHERE와 AND 조합 |
<set> |
조건에 따라 만든 update 객체 |
<choose>에 해당하는 조건은 다음처럼 쓴다.
const query = knex("TB_PROJECT")
.select("*")
.modify((qb) => {
if (managerId) {
qb.where("MANAGER_ID", managerId);
} else if (companyId) {
qb.where("COMPANY_ID", companyId);
} else {
qb.where("STATUS", "PUBLIC");
}
});
<foreach>로 목록을 만들던 조건은 whereIn으로 옮긴다.
const query = knex("TB_PROJECT")
.select("*")
.modify((qb) => {
if (statuses.length > 0) {
qb.whereIn("STATUS", statuses);
}
});
같은 필터가 여러 API에서 반복되면 별도 함수로 분리한다.
type ProjectFilters = {
status?: string;
companyId?: string;
};
function applyProjectFilters(
qb: Knex.QueryBuilder,
filters: ProjectFilters,
) {
if (filters.status) {
qb.where("STATUS", filters.status);
}
if (filters.companyId) {
qb.where("COMPANY_ID", filters.companyId);
}
}
const rows = await knex("TB_PROJECT")
.select("*")
.modify(applyProjectFilters, filters);
박개발 사무관은 주의할 점도 적었다.
undefined를 그대로where값으로 넘기지 않는다.- 정렬 컬럼과 방향은 사용자 문자열을 그대로 사용하지 않고 허용 목록으로 제한한다.
- 빈 배열의 의미를 정한 뒤
whereIn을 호출한다. - 복잡한 괄호 조건은 콜백
where로 묶어 우선순위를 명확히 한다. - 값은 바인딩하고, 컬럼명과 SQL 조각을 문자열 보간하지 않는다.
- 동적 SQL의 모든 중요 조건 조합을 테스트한다.
수집한 sql.log.날짜는 어떤 조건 조합이 실제로 사용됐는지 알려 준다. 팀은 그 조합을 Knex .modify() 테스트 사례로 옮기고, MyBatis 결과와 신규 쿼리 결과를 비교하기로 했다.
세 번째 회의의 결론
최정보 팀장은 SQL 수집의 완료 조건을 정했다.
1. SQL은 애플리케이션 일반 로그와 분리한다.
2. sql.log는 날짜별로 롤링한다.
3. raw SQL, 파라미터와 진단용 렌더링 SQL을 함께 남긴다.
4. Mapper ID, 요청 ID, 실행 시간과 결과 건수를 기록한다.
5. 개인정보와 비밀정보는 마스킹한다.
6. 상세 수집은 개발계와 테스트베드의 승인 기간에만 수행한다.
7. 로그는 신규 API의 계약과 비교 테스트 자료로 사용한다.
8. MyBatis 동적 조건은 Knex `.modify()`와 명시적 조건문으로 옮긴다.
정시스템 주무관이 테스트베드에서 프로젝트 목록을 한 번 조회했다. 일반 애플리케이션 로그에는 요청 완료 기록만 남았다. SQL 전용 파일에는 Mapper ID와 바인딩 전후의 진단 정보가 기록됐다.
sql.log.2026-10-12
김전산은 그 파일을 새 API의 정답이라고 부르지 않았다. 기존 시스템이 실제로 어떤 질문을 Oracle에 보냈는지 보여 주는 관찰 기록이라고 불렀다.
새 Next.js API는 그 기록에서 시작한다. 그리고 같은 입력에서 같은 업무 결과를 내는지는 별도의 자동 비교로 증명한다.