[Developer] #567 filter recent SQL cursors in Java

This commit is contained in:
devmrko
2026-07-01 15:06:41 +09:00
parent 1c66b973a7
commit d301e4dad1
4 changed files with 71 additions and 31 deletions

View File

@@ -138,7 +138,7 @@
## 13. SQL_ID 실행 증적 확장 — 2026-07-01 ## 13. SQL_ID 실행 증적 확장 — 2026-07-01
재현 SQL만으로는 “이 조건이 실제 DB cursor에 적용됐는가”를 충분히 설명하지 못한다. `/probe` 성공·0행 결과 뒤에 백오피스는 `V$SQL`에서 최근 2분 내 같은 보호 객체를 조회한 cursor를 찾아 SQL_ID와 실행 통계를 표시한다. 이어 `DBMS_XPLAN.DISPLAY_CURSOR(SQL_ID, CHILD_NUMBER, 'ALLSTATS LAST +PREDICATE +ALIAS')`의 Predicate Information을 함께 표시한다. 재현 SQL만으로는 “이 조건이 실제 DB cursor에 적용됐는가”를 충분히 설명하지 못한다. `/probe` 성공·0행 결과 뒤에 백오피스는 `V$SQL`에서 최근 15분의 최신 cursor 10건을 먼저 읽고, Java에서 `SQL_FULLTEXT` 보호 객체명이 있는 cursor만 고른다. DB에서 SQL 텍스트를 `LIKE`로 필터링하지 않아 ORDS Handler의 PL/SQL·공백·긴 SQL 표현 때문에 놓치는 문제를 줄인다. 이어 `DBMS_XPLAN.DISPLAY_CURSOR(SQL_ID, CHILD_NUMBER, 'ALLSTATS LAST +PREDICATE +ALIAS')`의 Predicate Information을 함께 표시한다.
| 화면 증적 | DB 출처 | 의미 | | 화면 증적 | DB 출처 | 의미 |
|---|---|---| |---|---|---|
@@ -150,6 +150,6 @@ Oracle은 VPD의 내부 rewrite 결과를 별도 최종 SQL 문자열로 `V$SQL`
화면에서는 설명용 표현을 **실행 요청 SQL (재현)**, cursor에서 읽은 원문을 **실제 DB cursor SQL**로 구분한다. `V$SQL` 권한이 없는 환경에서도 재현 SQL은 표시하고, 실제 cursor 증적이 없다는 이유를 별도로 표시한다. 화면에서는 설명용 표현을 **실행 요청 SQL (재현)**, cursor에서 읽은 원문을 **실제 DB cursor SQL**로 구분한다. `V$SQL` 권한이 없는 환경에서도 재현 SQL은 표시하고, 실제 cursor 증적이 없다는 이유를 별도로 표시한다.
최근 SQL 매칭은 대상 객체와 최근 2분 실행 시각으로 고르므로, 같은 객체를 동시에 호출하는 운영 환경에서는 다른 요청 cursor가 선택될 가능성이 있다. 현재 UI는 이를 “최근 실행 증적”으로 명시한다. 요청별 완전 상관이 필요해지면 ORDS Handler에 검증 요청 ID를 주입해 고유 SQL comment/module-action으로 cursor를 추적하는 후속 작업으로 확장한다. 최근 SQL 매칭은 최신 10건과 보호 객체명으로 고르므로, 같은 객체를 동시에 호출하는 운영 환경에서는 다른 요청 cursor가 선택될 가능성이 있다. 현재 UI는 이를 “최근 실행 증적”으로 명시한다. 요청별 완전 상관이 필요해지면 ORDS Handler에 검증 요청 ID를 주입해 고유 SQL comment/module-action으로 cursor를 추적하는 후속 작업으로 확장한다.
실행 증적 연결에는 최소한 `V$SQL``DBMS_XPLAN.DISPLAY_CURSOR`를 조회할 수 있는 catalog 권한이 필요하다. 증적은 ORDS parsing schema가 아니라 화면을 표시하는 `BACKOFFICE_DB_USERNAME` 연결에서 조회한다. 실제 SQL 실행 계정과 진단 조회 계정은 달라도 된다. Autonomous의 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 다른 계정에 위임하지 못할 수 있으므로, 이 경우에는 DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해야 한다. cursor 미발견과 catalog 권한 미보유는 UI에서 서로 다른 안내로 표시하며, 어느 경우도 권한 검증 결과를 실패시키지 않는다. 실행 증적 연결에는 최소한 `V$SQL``DBMS_XPLAN.DISPLAY_CURSOR`를 조회할 수 있는 catalog 권한이 필요하다. 증적은 ORDS parsing schema가 아니라 화면을 표시하는 `BACKOFFICE_DB_USERNAME` 연결에서 조회한다. 실제 SQL 실행 계정과 진단 조회 계정은 달라도 된다. Autonomous의 일반 `ADMIN` 계정은 `SYS.V_$SQL` 권한을 다른 계정에 위임하지 못할 수 있으므로, 이 경우에는 DBA가 [34_agent_ords_execution_evidence_grant.sql](../../sql/adb/34_agent_ords_execution_evidence_grant.sql)을 실행해야 한다. cursor 미발견과 catalog 권한 미보유는 UI에서 서로 다른 안내로 표시하며, 어느 경우도 권한 검증 결과를 실패시키지 않는다.

View File

@@ -45,6 +45,7 @@ public class OrdsProbeService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
}; };
private static final Logger log = LoggerFactory.getLogger(OrdsProbeService.class); private static final Logger log = LoggerFactory.getLogger(OrdsProbeService.class);
private static final int EXECUTION_EVIDENCE_LOOKBACK_MINUTES = 15;
private record ExecutionEvidenceLookup( private record ExecutionEvidenceLookup(
SqlExecutionEvidence evidence, SqlExecutionEvidence evidence,
@@ -52,6 +53,18 @@ public class OrdsProbeService {
) { ) {
} }
private record RecentSqlCursor(
String sqlId,
int childNumber,
String sqlFulltext,
String lastActiveAt,
long executions,
long rowsProcessed,
long elapsedMillis,
long bufferGets
) {
}
private final BearerTokenService tokenService; private final BearerTokenService tokenService;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
private final AuditService auditService; private final AuditService auditService;
@@ -260,8 +273,8 @@ public class OrdsProbeService {
+ "V$SQL과 DBMS_XPLAN은 일반 데이터 접근과 별도인 SYS 진단 권한입니다."); + "V$SQL과 DBMS_XPLAN은 일반 데이터 접근과 별도인 SYS 진단 권한입니다.");
} }
return result.withExecutionEvidence(null, return result.withExecutionEvidence(null,
"최근 2분 내 이 보호 대상의 실행 cursor를 찾지 못했습니다. " "최근 15분의 최신 실행 cursor 10건에서 이 보호 대상을 찾지 못했습니다. "
+ "해당 ORDS 요청이 DB까지 실행됐는지 확인하세요."); + "ORDS 응답 지연 또는 shared pool 교체로 증적이 남지 않았을 수 있습니다.");
} }
String message = evidence.hasPredicatePlan() ? null String message = evidence.hasPredicatePlan() ? null
: "SQL_ID는 찾았지만 DBMS_XPLAN Predicate Information을 읽지 못했습니다. " : "SQL_ID는 찾았지만 DBMS_XPLAN Predicate Information을 읽지 못했습니다. "
@@ -299,9 +312,7 @@ public class OrdsProbeService {
JdbcTemplate evidenceJdbcTemplate, JdbcTemplate evidenceJdbcTemplate,
ProtectedObject object ProtectedObject object
) { ) {
String marker = "%FROM%" + object.owner().toUpperCase(Locale.ROOT) List<RecentSqlCursor> candidates = evidenceJdbcTemplate.query("""
+ "." + object.objectName().toUpperCase(Locale.ROOT) + "%";
return evidenceJdbcTemplate.query("""
SELECT sql_id, SELECT sql_id,
child_number, child_number,
sql_fulltext, sql_fulltext,
@@ -320,31 +331,49 @@ public class OrdsProbeService {
elapsed_time, elapsed_time,
buffer_gets buffer_gets
FROM v$sql FROM v$sql
WHERE UPPER(sql_text) LIKE ? WHERE last_active_time >= SYSTIMESTAMP - NUMTODSINTERVAL(?, 'MINUTE')
AND last_active_time >= SYSTIMESTAMP - INTERVAL '2' MINUTE
ORDER BY last_active_time DESC ORDER BY last_active_time DESC
) )
WHERE ROWNUM = 1 WHERE ROWNUM <= 10
""", resultSet -> { """, (resultSet, rowNum) -> new RecentSqlCursor(
if (!resultSet.next()) { resultSet.getString("sql_id"),
return null; resultSet.getInt("child_number"),
} resultSet.getString("sql_fulltext"),
String sqlId = resultSet.getString("sql_id"); resultSet.getTimestamp("last_active_time") == null
int childNumber = resultSet.getInt("child_number"); ? null : resultSet.getTimestamp("last_active_time").toLocalDateTime().toString(),
String predicatePlan = findPredicatePlan(evidenceJdbcTemplate, sqlId, childNumber); resultSet.getLong("executions"),
return new SqlExecutionEvidence( resultSet.getLong("rows_processed"),
sqlId, Math.round(resultSet.getLong("elapsed_time") / 1000.0d),
childNumber, resultSet.getLong("buffer_gets")
resultSet.getString("sql_fulltext"), ), EXECUTION_EVIDENCE_LOOKBACK_MINUTES);
resultSet.getTimestamp("last_active_time") == null
? null : resultSet.getTimestamp("last_active_time").toLocalDateTime().toString(), return candidates.stream()
resultSet.getLong("executions"), .filter(candidate -> referencesProtectedObject(candidate.sqlFulltext(), object))
resultSet.getLong("rows_processed"), .findFirst()
Math.round(resultSet.getLong("elapsed_time") / 1000.0d), .map(candidate -> new SqlExecutionEvidence(
resultSet.getLong("buffer_gets"), candidate.sqlId(),
predicatePlan candidate.childNumber(),
); candidate.sqlFulltext(),
}, marker); candidate.lastActiveAt(),
candidate.executions(),
candidate.rowsProcessed(),
candidate.elapsedMillis(),
candidate.bufferGets(),
findPredicatePlan(evidenceJdbcTemplate, candidate.sqlId(), candidate.childNumber())
))
.orElse(null);
}
static boolean referencesProtectedObject(String sqlFulltext, ProtectedObject object) {
if (sqlFulltext == null || object == null) {
return false;
}
String normalizedSql = sqlFulltext.toUpperCase(Locale.ROOT)
.replace("\"", "")
.replaceAll("\\s+", "");
String objectName = (object.owner() + "." + object.objectName())
.toUpperCase(Locale.ROOT);
return normalizedSql.contains(objectName);
} }
private String findPredicatePlan( private String findPredicatePlan(

View File

@@ -112,7 +112,7 @@
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div> <div>
<h3>DB 실행 증적</h3> <h3>DB 실행 증적</h3>
<p class="section-subtitle">최근 2분 내 같은 보호 대상에 실행된 ORDS SQL을 SQL_ID로 확인한 결과입니다.</p> <p class="section-subtitle">최근 15분의 최신 DB cursor 10건에서 같은 보호 대상을 찾아 SQL_ID로 확인한 결과입니다.</p>
</div> </div>
<span class="badge text-bg-success" th:text="${'SQL_ID ' + result.executionEvidence().sqlId()}">SQL_ID</span> <span class="badge text-bg-success" th:text="${'SQL_ID ' + result.executionEvidence().sqlId()}">SQL_ID</span>
</div> </div>

View File

@@ -35,4 +35,15 @@ class OrdsProbeServiceTest {
.contains("WHERE d.embedding IS NOT NULL ORDER BY score") .contains("WHERE d.embedding IS NOT NULL ORDER BY score")
.doesNotContain("AND (1 = 1)", "REGEXP_LIKE"); .doesNotContain("AND (1 = 1)", "REGEXP_LIKE");
} }
@Test
void filtersRecentCursorCandidatesInJavaByTheProtectedObjectName() {
ProtectedObject object = new ProtectedObject(
1L, "ADMIN", "CB_V_SEARCH_DOCUMENTS", "cb-ords/cb-object-query/admin/documents", "Y");
assertThat(OrdsProbeService.referencesProtectedObject(
"SELECT *\nFROM ADMIN.CB_V_SEARCH_DOCUMENTS o WHERE ROWNUM <= :1", object)).isTrue();
assertThat(OrdsProbeService.referencesProtectedObject(
"SELECT * FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS", object)).isFalse();
}
} }