테마
11화 · 파고들기
필요할 때 찾아보는 문서다. 처음 읽을 때는
1_사건.md만으로 충분하다. 이 문서의 숫자는 모두ora-hanorder(10화를 마친 상태:T_ORD_BIG.ORD_STFREQUENCY 히스토그램, 인덱스 3개)에서 잰 값이다. 실습 스크립트 번호도 함께 적는다.
| 찾는 것 | 바로 가기 |
|---|---|
| 라이브러리 캐시 · KGL 핸들 · heap 0 / heap 6 · 부모와 자식 | §1 구조 |
| 파싱 네 가지가 각각 무엇을 건너뛰나 · TKPROF의 Parse 줄 | §2 파싱 |
| 하드 파싱의 값: 3만 번 클릭 · 밀려나는 이웃 · 네 명 동시 | §3 하드 파싱 |
"같은 SQL"의 기준 · FORCE_MATCHING_SIGNATURE · 탐지 쿼리 | §4 같은 SQL |
자식이 갈라지는 이유 · V$SQL_SHARED_CURSOR 읽는 법 | §5 자식 |
| 바인드 피킹 · 편향 컬럼 + 히스토그램 | §6 피킹 |
ACS: IS_BIND_SENSITIVE / IS_BIND_AWARE · 움직일 때와 못 움직일 때 | §7 ACS |
CURSOR_SHARING=FORCE의 이득과 함정 · 조각별 리터럴 지도 | §8 FORCE |
바인드 피킹을 끄면 · DBMS_SQL의 "값 없는 파싱" | §9 피킹 끄기 |
OR-Expansion의 두 얼굴(08화 :C IS NULL과 rev02) | §10 OR |
| Q4 rev03 · 정합성 회귀 체크리스트 | §11 rev03 |
| IN 리스트 길이가 매번 다를 때 | §12 IN |
ORA-04031: Free에서 본 것과 못 본 것 | §13 04031 |
| 측정값 전부 | §14 측정 |
| 원천 좌표 | §15 좌표 |
1. 라이브러리 캐시 — 핸들과 힙
공유 풀(shared pool) 안의 라이브러리 캐시는 SQL, PL/SQL, 테이블 정의처럼 "실행에 필요한 물건"을 이름으로 찾아 쓰는 캐비닛이다. 물건 하나가 곧 KGL 객체 하나이고, 핸들 하나다.
SQL 텍스트 ──해시──▶ 해시 버킷 ──▶ [부모 핸들] ── heap 0 : 텍스트 · 자식 목록 · 의존 객체
│
├── [자식 핸들 0] ── heap 0 : 환경 · 바인드 정보
│ └ heap 6 : 실행계획 (가장 큰 덩어리)
└── [자식 핸들 1] ── heap 0 · heap 6실측 — Q1 rev03을 ALL_ROWS · FIRST_ROWS_10으로 한 번씩 (실습/91 · SYSDBA · X$KGLOB)
| 객체 | 핸들의 부모 (KGLHDPAR) | 자식 번호 | heap 0 | heap 6 |
|---|---|---|---|---|
| 부모 | 자기 자신 | 65535 | 4,064 | 없음 |
| 자식 0 | 부모 | 0 | 8,080 | 36,416 |
| 자식 1 | 부모 | 1 | 8,080 | 36,416 |
- 실행계획(heap 6)은 자식에만 있다. 하드 파싱으로 만드는 것 중 가장 큰 것이 이것이다.
- 같은 것을 V$ 뷰로 보면
V$SQL.SHARABLE_MEM은 자식마다 48,928,V$SQLAREA.SHARABLE_MEM(부모 합계)은 93,424다.
V$SQLAREA와 V$SQL — 부모와 자식
| 뷰 | 한 줄 = | 쓰는 곳 |
|---|---|---|
V$SQLAREA | 부모 커서 (SQL 텍스트 하나) | VERSION_COUNT · 부모 단위 메모리 · "같은 문장이 몇 개인가" |
V$SQL | 자식 커서 | 계획 · 실행 통계 · IS_BIND_* · CHILD_NUMBER |
V$SQL_SHARED_CURSOR | 자식 커서 | 이 자식이 왜 따로 생겼나(§5) |
⚠️
V$SQL.SHARABLE_MEM은 더하면 안 된다. 자식 줄마다 부모 몫이 섞여 있다. FORCE로 자식이 329개 생긴 Q4는V$SQL합계가 113.1MB,V$SQLAREA는 10.5MB였다(실습/01[H]). 자식 하나의SHARABLE_MEM이 360,389바이트로 찍히는 것은, 자식 목록만큼 커진 부모의 heap 0이 줄마다 함께 들어가기 때문이다. 부모 단위 메모리는V$SQLAREA로 본다.
2. 파싱 네 가지
애플리케이션이 SQL을 보내면 서버는 파스 호출(parse call)을 받는다. 그 뒤에 무슨 일이 일어나느냐에 따라 네 가지로 나뉜다.
| 이름 | 파스 호출 | 라이브러리 캐시에서 찾기 | 최적화 · 실행계획 생성 | 서식함 비유 | |
|---|---|---|---|---|---|
| ① | 하드 파싱 | 있다 | 찾았는데 없다 | 한다 | 총무팀에 새 양식을 주문 |
| ② | 소프트 파싱 | 있다 | 해시 체인을 따라가 찾는다 | 건너뛴다 | 서식함까지 가서 칸을 찾아 꺼냄 |
| ③ | 세션 커서 캐시 적중 | 있다 | 내 세션이 기억해 둔 자리로 바로 간다 | 건너뛴다 | 칸 위치를 적어 둔 수첩 |
| ④ | 커서를 열어 둔 채 재실행 | 없다 | 안 간다 | 건너뛴다 | 책상 위에 펼쳐 둔 양식 |
- ②와 ③을 가르는 것은
SESSION_CACHED_CURSORS다(이 컨테이너 기본값 50). 같은 문장을 몇 번 닫았다 다시 열면 세션이 그 커서의 위치를 기억하고,session cursor cache hits통계가 오른다. - ④는 애플리케이션이 커서를 닫지 않고 바인드 값만 바꿔 다시 실행하는 것이다. PL/SQL은 같은 동적 SQL을 연달아 부르면 알아서 이렇게 한다.
EXECUTE IMMEDIATE ... USING을 3만 번 불러도 파스 호출은 1번이다(실습/00[B]). 화면 프로그램 쪽에서는 문장 캐시(statement cache)가 같은 역할을 한다.
실측 — 같은 2,000번 클릭 (실습/05 · DBMS_SQL로 파스 호출을 직접 쥔다)
| 걸린 시간 (cs) | parse count (total) | parse count (hard) | session cursor cache hits | parse time cpu (cs) | CPU (cs) | logical reads | |
|---|---|---|---|---|---|---|---|
| ① 하드 (리터럴) | 167 · 170 | 2,059 · 2,062 | 2,034 · 2,000 | — | 155 · 162 | 163 · 170 | 32,204 · 31,580 |
| ② 소프트 (캐시 0) | 8 · 7 | 2,008 | 4 | 0 | 1 · 0 | 7 | 30,964 |
| ③ 세션 캐시 (캐시 50) | 6 · 9 | 2,001 | 4 | 2,004 | 1 | 6 · 8 | 30,964 |
| ④ 열어 둔 채 | 4 · 5 | 2 | 3 | 7 | 0 | 5 | 30,964 |
(두 값은 1회차 · 2회차. 문장은 Q4 rev03. logical reads 30,964는 클릭당 15.5블록)
- 세션 하나, 이 정도 크기에서는 ②③④가 구별되지 않는다. 차이는 여럿이 동시에 같은 문장을 찾을 때 난다(§3).
- 하드 파싱이 4, 3으로 찍힌 것은 첫 파싱과 재귀 SQL 몫이다.
TKPROF의 Parse 줄 (실습/90 · 10046 level 12 · 20번씩)
[L] 리터럴 — 문장 20개. 표마다:
call count ... query rows
Parse 1 0 0
Execute 1 0 0
Fetch 1 14 11
Misses in library cache during parse: 1 ← 하드 파싱
[S] rev03 · 클릭마다 파스 호출 — 문장 1개:
Parse 20 0 0
Execute 20 0 0
Fetch 20 280 220
Misses in library cache during parse: 1 ← 첫 한 번만
Misses in library cache during execute: 1
[O] rev03 · 열어 둔 채 — 문장 1개:
Parse 1 0 0
Execute 20 0 0
Fetch 20 280 220
OVERALL TOTALS FOR ALL RECURSIVE STATEMENTS
Misses in library cache during parse: 33 ← L 20 + S 1 + O 1 + 딕셔너리 재귀 SQL| TKPROF에서 읽는 것 | 뜻 |
|---|---|
Parse 줄의 count | 파스 호출 수. 실행 수와 같으면 애플리케이션이 매번 파싱하는 것이고(②③), 1이면 열어 둔 채 쓰는 것이다(④) |
Misses in library cache during parse | 하드 파싱 수 |
Misses in library cache during execute | 실행 때 계획을 다시 세운 수. DBMS_SQL처럼 값을 묶기 전에 파싱했거나 계획이 무효화된 경우다 |
Row Source의 card= | 옵티마이저가 본 행 수. [S]는 card=49957(값을 못 엿봤을 때의 추정으로 100만의 5% 언저리), [O]는 card=999990(엿본 값)이다. 같은 문장이라도 값을 엿봤는지가 여기서 드러난다(§9) |
3. 하드 파싱의 값
정산 목록 3만 번 클릭 (실습/00 · 단일 세션)
| [A] 개발팀: 포스트잇을 문자열로 | [B] rev02 파일: 바인드 | |
|---|---|---|
| 걸린 시간 | 35.3~36.4초 (4회) | 0.67초 (세 번 다) |
| parse count (hard) | 30,000~30,431 (4회) | 1 |
| parse time cpu | 3,466~3,493cs | 0 |
| CPU used by this session | 3,617cs (두 번 다) | 67 · 68cs |
| session logical reads | 522,931 · 517,487 (클릭당 17.4 · 17.2) | 466,584 (클릭당 15.6) |
| recursive calls | 154,449 · 145,984 | 30,009 |
| 공유 풀에 남은 문장 | V$SQL 308~481 (3만 개 중. 공유 풀 사정에 따라 실행마다 다르다) | 1 (실행 30,000) |
- CPU의 96%가 파싱이다(3,466 / 3,617). 데이터를 읽는 데가 아니라 "어떻게 읽을지 정하는 데" 썼다.
- 하드 파싱도 블록을 읽는다. 클릭당 약 2블록으로, 딕셔너리를 찾는 재귀 SQL 몫이다.
- 3만 개 중 몇백 개만 남았다. 공유 풀은 커서를 다 쥐고 있지 않고 오래 안 쓴 것부터 내보낸다.
밀려나는 이웃
[A0]에서 Q1 rev03을 한 번 실행해 V$SQL에 올린 뒤 [A]를 돌렸다. [A] 직후 Q1은 V$SQL에 없었다(두 번 다). Q1은 아무 잘못도 없이 다음 실행 때 다시 하드 파싱된다.
"공유 풀에 같은 SQL이 수만 개"라는 말은 반만 맞다. 수만 개가 쌓여 있는 것이 아니라 지나간다. 들어올 때마다 하드 파싱을 하고, 나갈 때는 이웃을 데리고 나간다.
네 명 동시 (실습/08 · 07화 03 시리즈 방식)
| 클릭 1,000번당 · 네 명 평균 | 1회차 리터럴 | 2회차 리터럴 | 1회차 바인드 | 2회차 바인드 | 1회차 소프트 |
|---|---|---|---|---|---|
| 걸린 시간 (cs) | 204.6 | 171.4 | 5.1 | 5.4 | 5.5 |
| CPU (cs) | 135.5 | 136.5 | 3.4 | 3.6 | 3.6 |
| Concurrency 대기 (cs) | 9.58 | 11.25 | 0.11 | 0.15 | 0.28 |
resmgr:cpu quantum (cs) | 60.3 | 24.3 | 1.6 | 1.7 | 1.7 |
| 하드 파싱 (네 명 합) | 12,007 | 12,007 | 4 | 4 | 4 |
(리터럴 일꾼은 각자 3,000번, 바인드와 소프트 일꾼은 각자 20,000번. 바인드 일꾼은 클릭마다 커서를 열고, 파싱하고, 닫는다. 문장 캐시가 없는 화면 프로그램과 같다. 바인드 문장은 rev03)
- 리터럴 일꾼 한 명의 대기 맨 위에 있는 것은
latch: shared pool(3,000번 동안 23~25cs)로, 공유 풀에 새 자리를 만들 때 잡는 래치다. 그다음은row cache mutex(2~5cs, 딕셔너리 캐시)다. - 소프트(캐시 0) 일꾼의 대기 맨 위는
library cache: mutex X(2~5cs)다. 세션 캐시를 켠 바인드 일꾼은 1~3cs였다. ②와 ③의 차이는 여럿이 함께 쓸 때 이렇게 드러나지만, 크지는 않다. resmgr:cpu quantum은 Free가 CPU를 2개로 묶어 두어서 생기는 줄서기다(07화에서 본 것). 실행마다 크게 흔들린다(60.3, 24.3, 3회차 7). 흔들리지 않는 숫자는 CPU 열이다(3회차 리터럴 132.3, 바인드 3.5).
Free 한 대에서 만들 수 있는 동시성은 여기까지다. 수백 세션이 하드 파싱을 두고 다투는 규모의 래치와 뮤텍스 경합은 이 교재의 범위 밖이다(별권).
4. 같은 SQL인가 — 텍스트와 지문
오라클이 "같은 문장"으로 보는 기준은 텍스트다. 대소문자, 공백, 주석, 리터럴 중 하나만 달라도 다른 부모 커서가 된다.
| 컬럼 | 무엇이 같으면 같은 값인가 | 쓰는 곳 |
|---|---|---|
SQL_ID / HASH_VALUE | 텍스트가 바이트까지 같다 | 부모 커서를 가리킬 때 |
EXACT_MATCHING_SIGNATURE | 텍스트가 같다 (공백 · 대소문자를 정규화한 뒤) | SQL Profile 등 |
FORCE_MATCHING_SIGNATURE | 리터럴을 바인드로 바꿔 놓고 보면 같다 | 리터럴만 다른 무리 찾기 |
이 화의 실습은 측정 문장마다
/* CH11 [태그] R+시각 */주석을 붙인다. 주석도 텍스트다. 표식이 다르면 다른 문장이 된다. 이 성질을 이용해 실행마다 새 문장 무리를 만들어서 앞 실행의 문장과 섞이지 않게 했다. 반대로 공유돼야 할 문장끼리는 표식이 같아야 한다(같은 구간 안에서는 표식을 바꾸지 않는다).
탐지 쿼리 — 공유 풀에서 "리터럴만 다른 문장" 무리 (실습/00 [C])
sql
SELECT force_matching_signature AS fms,
COUNT(*) AS parents, -- 리터럴만 다른 부모 커서 수
SUM(version_count) AS children,
SUM(executions) AS execs,
ROUND(SUM(sharable_mem)/1024/1024, 1) AS mb,
SUBSTR(MIN(sql_text), 1, 60) AS sample
FROM v$sqlarea -- 부모 단위 (§1 의 경고)
WHERE force_matching_signature <> 0
GROUP BY force_matching_signature
HAVING COUNT(*) >= 50
ORDER BY parents DESC
FETCH FIRST 5 ROWS ONLY;실측해 보니 [A] 직후 맨 위가 [A] 무리였다(부모 301개와 397개, 각각 1회차와 2회차). PARENTS가 크고 EXECS가 그와 비슷하면 한 번 쓰고 버리는 문장이다.
5. 자식 커서가 갈라지는 이유
부모(텍스트)가 같아도 같이 쓸 수 없는 사정이 있으면 자식이 새로 생긴다. 그 사정은 V$SQL_SHARED_CURSOR에 칸(Y/N)으로 적힌다.
Y인 칸만 뽑는 법 (실습/01 · 02 · 06)
칸 이름은 버전마다 조금씩 다르다. 그래서 길이 1짜리 VARCHAR2 칸을 전부 모아 UNPIVOT한다.
sql
DECLARE
cols VARCHAR2(32767); TYPE rc IS REF CURSOR; c rc; ch NUMBER; col VARCHAR2(128);
BEGIN
SELECT LISTAGG(column_name, ',') WITHIN GROUP (ORDER BY column_id) INTO cols
FROM all_tab_columns
WHERE owner = 'SYS' AND table_name = 'V_$SQL_SHARED_CURSOR' AND data_type = 'VARCHAR2' AND data_length = 1;
OPEN c FOR 'SELECT child_number, col FROM (SELECT child_number, ' || cols
|| ' FROM v$sql_shared_cursor WHERE sql_id = ''&sql_id'')'
|| ' UNPIVOT (v FOR col IN (' || cols || ')) WHERE v = ''Y'' ORDER BY 1';
LOOP FETCH c INTO ch, col; EXIT WHEN c%NOTFOUND; DBMS_OUTPUT.PUT_LINE('child ' || ch || ' : ' || col); END LOOP;
CLOSE c;
END;
/이 화에서 만난 사유
| 칸 | 무엇 때문에 | 이 화의 실측 |
|---|---|---|
BIND_LENGTH_UPGRADEABLE | 바인드 변수의 그릇이 기존 자식보다 크다 | Q1 rev03 · VAR p_ym VARCHAR2(6) → VARCHAR2(200). 그릇 32 → 2000바이트(V$SQL_BIND_METADATA.MAX_LENGTH, 실습/06) |
OPTIMIZER_MODE_MISMATCH | 세션의 optimizer_mode가 다르다 | Q1 rev03 · FIRST_ROWS_10 세션 (실습/06) |
HASH_MATCH_FAILED | FORCE가 "안전하지 않은 리터럴"로 본 값이 다르다 | Q4 · DATE '2024-12-30'과 DATE '2024-12-29' → 자식 0 · 1 (실습/01 [B]) |
BIND_EQUIV_FAILURE | ACS가 이 값 범위에는 다른 자식이 필요하다고 판단 | 상태 탭 100건 · 취소 값 (실습/02 [C]) |
LOAD_OPTIMIZER_STATS | ACS를 시작하려고 하드 파싱을 강제 | 같은 실험의 자식 0 (실습/02 [C]) |
실습/06의 결과는 부모 하나, VERSION_COUNT 3, 실행 5, LOADS 3이다. 자식 0은 IS_SHAREABLE = N(그릇이 작아서 더는 안 쓴다)이고, 자식 1은 보통 세션 몫, 자식 2는 FIRST_ROWS 몫이다.
📌 Y 칸과
REASON칸은 방향이 다르다 Y 칸은 "그 줄의 자식이 새로 생긴 이유"다(자식 1 줄에
BIND_LENGTH_UPGRADEABLE).REASON(XML)은 "그 줄의 자식을 다음 파스가 쓰지 못한 이유"에 가깝다. 같은 실험에서 자식 0 줄의 REASON은Bind mismatch(22), 자식 1 줄은Optimizer mismatch(10)이었다. 둘을 섞어 읽으면 한 칸씩 어긋난다. "왜 자식이 생겼나"는 Y 칸으로 답한다.
6. 바인드 피킹
바인드 변수를 쓰면 SQL 텍스트에는 값이 없다. 그래도 오라클은 하드 파싱하는 순간 그 바인드에 들어 있는 값을 엿보고, 그 값이면 몇 건이 나올지 셈해서 계획을 세운다. 그 뒤에는 어떤 값이 오든 그 계획을 같이 쓴다.
피킹은 다음 두 조건이 겹칠 때 문제가 된다.
- 값마다 건수가 크게 다르고(편향, skew), 오라클이 그 사실을 안다(히스토그램).
- 건수에 따라 고를 길이 둘 이상 있다.
T_ORD_BIG.ORD_ST — 10화 종료 상태
| 값 | 행수 | 비율 | 히스토그램 (ENDPOINT_NUMBER 누적) |
|---|---|---|---|
CANCEL | 20,000 | 2% | 20,000 |
COMPLETE | 900,000 | 90% | 920,000 |
PENDING | 80,000 | 8% | 1,000,000 |
T_ORD_BIG에는 ORD_ST 인덱스가 없다. 그래서 "상태 = X"만 묻는 문장이면 고를 길이 하나(FULL)뿐이고, 피킹이 해를 끼칠 자리도 없다. 길이 둘이 되는 것은 상태 조건과 정렬이 함께 올 때다. 최근 순 인덱스 IX_ORD_BIG_DT_SEQ를 거꾸로 내려가며 상태를 거를지, 테이블을 다 읽고 정렬할지 골라야 한다.
정산 상태 확인 화면 — 최근 1,000건 (실습/01 [C])
sql
SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
FROM (SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT FROM T_ORD_BIG
WHERE ORD_ST = 'CANCEL' ORDER BY ORD_DT DESC, ORD_SEQ DESC)
WHERE ROWNUM <= 1000;| 리터럴 (EXACT) | 계획 | 계획 해시 | Buffers |
|---|---|---|---|
'COMPLETE' | INDEX FULL SCAN DESCENDING + TABLE ACCESS BY INDEX ROWID | 369784633 | 1,016 |
'PENDING' | TABLE ACCESS FULL + SORT ORDER BY STOPKEY | 654798247 | 5,826 |
'CANCEL' | 〃 | 654798247 | 5,826 |
억지로 인덱스 계획을 쓰면 취소 50,347, 미결 13,510이다(§8 [D]). 취소는 50건에 한 건꼴이라, 1,000건을 채우려면 인덱스를 따라 약 5만 행을 테이블까지 가서 확인해야 한다.
이 데이터에는 버릇이 하나 있다. 미결과 취소는 생성 순번의 나머지(
MOD)로 정해져서 1,095일 중 219일에만 있고(닷새에 하루), 마지막 날은 12월 26일이다. 12월 27~30일에는 하나도 없다. 그래서 "최근 11건"처럼 아주 적게 뽑아도 인덱스 쪽은 취소 3,772블록, 미결 3,694블록을 지나야 했다(탐색 측정). 상관 컬럼 이야기는 10화에서 다룬 영역이다.
7. 적응적 커서 공유 (ACS)
| 표시 | 뜻 |
|---|---|
IS_BIND_SENSITIVE = Y | "값에 따라 계획이 갈릴 수 있다"는 표시. 히스토그램이 있는 컬럼의 = 바인드, 범위 바인드 등에 붙는다. 피킹을 했을 때만 붙는다(§9) |
IS_BIND_AWARE = Y | "값의 범위마다 자식을 따로 쓴다"로 바뀐 자식 |
V$SQL_CS_HISTOGRAM | 실행할 때마다 일량을 세 칸(BUCKET_ID 0 · 1 · 2) 중 하나에 센다 |
V$SQL_CS_SELECTIVITY | bind aware 자식이 맡은 선택도 범위 |
순서: bind sensitive 자식이 실행되면서 칸이 쌓인다 → 서로 다른 칸의 실행이 섞이면 → 다음 파스 호출 때 bind aware 자식을 새로 만들고 값을 다시 엿본다(BIND_EQUIV_FAILURE). 칸을 가르는 기준은 공식문서에 나와 있지 않다. 아래는 이 버전에서 잰 결과다.
언제 움직이고 언제 못 움직이나 (실습/02 · statistics_level = TYPICAL)
| 순서 | 한 번 실행 Buffers | CS_HISTOGRAM | 결과 | |
|---|---|---|---|---|
| [A] 1,000건 | 완료 → 취소 ×4 | 1,0xx → 50,347 ×4 | 1:5 | 못 고침: 다 1번 칸 |
| [B] 1,000건 | 취소 → 완료 ×4 | 5,826 → 5,826 ×4 | 1:5 | 못 고침 |
| [C] 100건 | 완료 → 취소 ×3 | 104 → 8,334 → 5,826 → 5,826 | 자식0 0:1 1:1 · 자식1 1:2 | 고침: 나쁜 한 번 뒤 자식 1 (선택도 0.018~0.022) |
| [E] 100건 · PL/SQL | 완료 → 취소 ×5 | 103 → 8,333 ×5 | — | 못 고침: 파스 호출 1번 |
- 1,034와 50,347은 50배 차이인데 같은 칸이다. 칸은 셋뿐이다.
- [C]처럼 고쳐져도 나쁜 실행이 최소 한 번 있다.
- [E]: PL/SQL이 커서를 열어 둔 채 다시 실행하면 파스 호출이 없어서 자식을 다시 고를 기회가 없다. 같은 문장, 같은 순서인데 SQL*Plus에서는 고쳐지고 PL/SQL에서는 안 고쳐졌다.
☠️ 재는 도구가 결과를 바꿨다 — statistics_level
[B]와 같은 순서를
statistics_level = ALL로 다시 재면(실습/02[D]) 완료 탭이 두 번째부터 1,016으로 고쳐진다. CS_HISTOGRAM은1:1 2:1로, 완료(FULL) 실행이 2번 칸에 들어갔다. TYPICAL에서는 1번 칸이었다.ALL(그리고GATHER_PLAN_STATISTICS힌트)은 실행계획 줄마다 행 수와 시간을 모으는 장치다.DISPLAY_CURSOR의ALLSTATS가 이 값을 쓴다. 이 장치를 켜자 ACS가 세는 일량이 달라졌다. 운영은 TYPICAL이다. 그래서 이 화의 ACS와 FORCE 측정은 TYPICAL에서 했고, 한 번 실행한Buffers는V$SQL.BUFFER_GETS가 늘어난 양으로 쟀다. 새 문장의 첫 실행에는 하드 파싱 중에 읽은 블록 수십 개가 더해진다(예: 완료 탭 첫 실행 1,016~1,065).
8. CURSOR_SHARING=FORCE — 이득과 함정
CURSOR_SHARING은 서버가 리터럴을 바인드로 바꿔 끼울지 정하는 파라미터로, 값은 EXACT(기본)와 FORCE가 있다. SIMILAR도 이 버전에서 설정은 받아들여지지만(측정으로 확인) 폐기 예정 값이라 다루지 않는다. 이 화의 실험은 모두 ALTER SESSION으로, 내 세션에만 켜고 끝에서 EXACT로 되돌렸다.
조각마다 박혀 있던 리터럴 (실습/01 [A][B])
| 조각 | 화면이 고르는 값 | 문장 안의 상수 | FORCE가 바꾼 텍스트 | EXACT → FORCE Buffers | 계획 |
|---|---|---|---|---|---|
| Q1 rev03 | 조회월: 이미 바인드 :p_ym(03화) | '01' · 'YYYYMMDD' · 1 | :p_ym||:"SYS_B_0" · :"SYS_B_1" … :"SYS_B_4" | 91 → 91 | 같음 (92368507) |
| Q2 rev01 | 없음 | 없음 | 그대로 | 5,869 → 5,868 | 같음 (2292855265) |
| Q3 rev01 | 없음 | SELECT 1 · FETCH FIRST 5 | SELECT :"SYS_B_0" · FETCH FIRST :"SYS_B_1" | 840 → 839 | 같음 (2231864090) |
| Q4 (개발팀 구현) | 포스트잇: 문자열로 이어 붙임 | 날짜 · 번호 · ROWNUM <= 11 | DATE :"SYS_B_0" … ROWNUM <= :"SYS_B_3" | 15 → 15 | 같음 (3610158121) |
| 상태 탭 (강도현) | 탭: 상수 | 'COMPLETE' 등 · 1000 | ORD_ST = :"SYS_B_0" · ROWNUM <= :"SYS_B_1" | 아래 표 | 첫 값이 정함 |
이득 — Q4 3만 번 클릭 (실습/01 [H])
| 하드 파싱 | 걸린 시간 | 공유 풀 | |
|---|---|---|---|
| EXACT (개발팀 구현) | 30,000~30,431 | 36.19초 | 부모 3만 개가 지나감 |
| FORCE | 329~334 | 1.45~1.55초 | 부모 1 · VERSION_COUNT 329 · 부모 10.5MB |
| 바인드 (rev02 · rev03) | 1 | 0.67초 | 부모 1 · 자식 1 |
FORCE는 효과가 있다. 다만 1이 아니라 329다. 3만 클릭이 지나간 서로 다른 날짜 수와 같다(하드 파싱 329~334에서 나머지는 재귀 SQL 몫).
함정 1 — DATE '...'는 값마다 자식 (HASH_MATCH_FAILED)
FORCE는 DATE '2024-12-30'을 DATE :"SYS_B_0"으로 바꿔 적지만, 이 자리는 안전하지 않은 리터럴로 다룬다. 값이 다르면 기존 자식을 못 쓰고 새로 만든다. 같은 날짜의 두 페이지는 자식 0을 같이 쓰고, 다른 날짜는 자식 1을 만들었다(실습/01 [B]). 탐색 측정에서 TO_DATE('20241230','YYYYMMDD') 형태는 자식 하나를 같이 썼다.
함정 2 — 일부러 박은 리터럴이 하나로 묶인다 (실습/01 [C]~[F])
| 상태 탭 · 최근 1,000건 | 완료 | 미결 | 취소 |
|---|---|---|---|
| EXACT (탭마다 문장) | 1,016 | 5,826 | 5,826 |
| FORCE · 첫 손님 완료 | 1,016 | 13,510 (2.3배) | 50,347 ×3 (8.6배) |
| FORCE · 첫 손님 취소 | 5,826 ×3 (5.7배) | — | 5,826 |
FORCE + /*+ CURSOR_SHARING_EXACT */ | 1,016 | — | 5,826 |
- 어느 쪽이든 누군가는 손해를 본다. 그리고 1,000건 화면에서는 ACS도 고치지 못했다(§7).
CURSOR_SHARING_EXACT힌트를 쓰면 FORCE를 켠 채로도 그 문장만은 리터럴을 지킨다. FORCE를 꼭 켜야 하는 날의 탈출구지만, 거꾸로 말하면 리터럴이 맞는 문장을 전부 찾아 힌트를 붙여야 한다.
함정 3 — 결과의 칸 길이가 바뀐다 (실습/01 [G])
SELECT SUBSTR(CUS_ID, 1, 4), 'A' FROM M_CUS의 결과 열 최대 길이(DBMS_SQL.DESCRIBE_COLUMNS):
SUBSTR(CUS_ID,1,4) | 'A' | |
|---|---|---|
| EXACT | 16바이트 | 1 |
| FORCE | 160 | 32 |
SUBSTR의 1과 4가 바인드로 바뀌면 오라클은 결과 길이를 컬럼 최대(40자 × 4바이트)로 잡는다. 결과 칸 길이에 기대는 고정폭 화면, 파일, 보고서가 깨진다. 실행계획과 상관없는 함정이라 Buffers로는 보이지 않는다.
9. 바인드 피킹을 끄면
_optim_peek_user_binds = FALSE로 끈다(밑줄로 시작하면 숨은 파라미터다. 운영에서는 오라클 지원 없이 바꾸지 않는다). 끄면 오라클은 값을 모르는 채로 계획을 세운다. 이 화에서는 세션에서만 끄고 바로 되돌렸다(실습/03).
| 문장 | 피킹 켬 (기본) | 피킹 끔 |
|---|---|---|
| 상태 탭 1,000건 (바인드) | 첫 값이 정함 (§7) | 셋 다 인덱스 계획: 완료 1,017 · 미결 13,510 · 취소 50,347. IS_BIND_SENSITIVE = N(ACS도 손을 놓는다) |
| Q4 rev02 (포스트잇 바인드) | 어디서 태어나든 14~17 | 2페이지 1,005,991 · 100페이지 1,005,006 · 1,000페이지 995,954 · 10,000페이지 905,413 · 마지막 18 |
| Q4 rev03 | 14~17 | 14~17 (14 · 14 · 17 · 16 · 16) |
값을 모른 채 파싱되는 다른 길 — DBMS_SQL
DBMS_SQL.PARSE는 BIND_VARIABLE보다 먼저 호출된다. 그래서 파싱하는 순간에는 엿볼 값이 없다. 계획의 Outline에도 피킹을 끈 것과 똑같이 찍힌다.
OPT_PARAM('_optim_peek_user_binds' 'false')실습/05 [5]에서 DBMS_SQL로 2페이지를 한 번 부르면 rev02는 1,005,990, rev03은 13~19였다. TKPROF의 Row Source에서도 차이가 보인다. DBMS_SQL로 부른 rev03은 card=49957(값을 못 엿봤을 때의 추정), EXECUTE IMMEDIATE ... USING으로 부른 rev03은 card=999990(엿본 값)이다. 바인드 수가 매번 다른 동적 SQL은 PL/SQL에서 DBMS_SQL로 짜게 된다(§12의 ②가 그 예다).
결론: 바인드 문장은 "엿본 값이 무엇이든, 엿보지 못해도 같은 계획이 나오는 모양"으로 쓴다. 이것이 SQL 코딩 가이드 v0.1의 3번 조문이다.
10. OR-Expansion의 두 얼굴
OR-Expansion은 OR로 이어진 조건을 갈래로 나눠 UNION-ALL로 합치는 변환이다. 계획에 VW_ORE_…가 보이면 이 변환이 일어난 것이다(08화 변환 도감 참고).
좋은 얼굴 — 08화 cust_id = NVL(:c, cust_id) (구 시스템 ORDERS · 실습/03 [E])
xplan
| 2 | VIEW | VW_ORE_9F22E1CA | Starts
| 3 | UNION-ALL | |
|* 4 | FILTER | | 1 ← :C IS NOT NULL
| 5 | TABLE ACCESS BY INDEX ROWID BATCHED| ORDERS | 1 / 0
|* 6 | INDEX RANGE SCAN | ORDERS_X_CUST | 1 / 0
|* 7 | FILTER | | 1 ← :C IS NULL
|* 8 | TABLE ACCESS FULL | ORDERS | 0 / 1:c | 인덱스 갈래 Starts | FULL 갈래 Starts | Buffers | 계획 해시 |
|---|---|---|---|---|
| 123 | 1 | 0 | 13 | 593186481 |
| NULL | 0 | 1 | 1,205 | 593186481 |
계획은 하나, 길은 둘이다. 어느 길을 탈지는 실행할 때 :C IS NULL 필터가 고른다. 08화가 넘긴 "바인드 값에 따라 갈리는 계획"의 답이 이것이다. 값이 바뀌어도 커서는 하나이고, 피킹이나 ACS에 기대지 않는다.
나쁜 얼굴 — Q4 rev02를 값 없이 파싱했을 때 (실습/03 [B])
xplan
|* 3 | SORT ORDER BY STOPKEY | |
| 4 | VIEW | VW_ORE_23B51052 |
| 5 | UNION-ALL | |
| 6 | TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG |
|* 7 | INDEX RANGE SCAN | IX_ORD_BIG_DT | ← ORD_DT < :p_dt (2페이지면 거의 전부)
| 8 | TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG |
|* 9 | INDEX RANGE SCAN | IX_ORD_BIG_DT_SEQ | ← ORD_DT = :p_dt AND ORD_SEQ <= :p_seq두 갈래를 둘 다 끝까지 읽고 합친 뒤 정렬해 11건을 자른다. 값을 알았다면(피킹) 첫 갈래가 거의 전부라는 걸 보고 펼치지 않았을 것이다. 실제로 피킹을 켜면 INDEX RANGE SCAN DESCENDING + STOPKEY 하나로 14블록이다.
펼칠 거리를 없앤다 — rev03
sql
-- rev02 : WHERE ORD_DT <= :p_dt AND (ORD_DT < :p_dt OR ORD_SEQ <= :p_seq)
-- rev03 : WHERE ORD_DT <= :p_dt AND NOT (ORD_DT = :p_dt AND ORD_SEQ > :p_seq)옵티마이저는 rev03의 NOT을 다시 ("ORD_DT"<>:P_DT OR "ORD_SEQ"<=:P_SEQ)로 풀어 쓰지만, 이번에는 펼칠 갈래가 아니라 인덱스 안에서 거르는 filter로 쓴다(피킹 켬, 피킹 끔, DBMS_SQL 모두 계획 해시 3610158121).
11. Q4 rev03
측정 (실습/04 · 진짜 DATE 바인드 · 피킹 켬)
| 페이지 | 1 | 2 | 100 | 1,000 | 10,000 | 마지막 (100,000) |
|---|---|---|---|---|---|---|
| Buffers | 14 | 14 | 15 | 17 | 16 | 16 (10건 · 다음 없음) |
| 문장 | 첫 페이지 | 다음 페이지 | 〃 | 〃 | 〃 | 〃 |
문장은 첫 페이지용과 다음 페이지용 둘이고, 하드 파싱도 두 번이다. 10만 페이지를 다 넘기는 동안 다음 페이지 문장은 LOADS 1, 실행 99,999, 페이지당 15.6블록이었다.
정합성 회귀 체크리스트 (08화 양식 · 실습/04)
| # | 항목 | 대상 / 방법 | 결과 | 판정 |
|---|---|---|---|---|
| A | 기준 | rev00 (OFFSET + 전체 정렬 · FULL 고정). 원장은 T_ORD_BIG 전체를 (ORD_DT DESC, ORD_SEQ DESC)로 정렬한 것 | — | 정함 |
| B | 건수 · 합계 · MINUS 양방향 · 중복 키 | rev03 (DATE 바인드) vs rev00, 100페이지 | 10 = 10 · 2,627,200 = 2,627,200 · 0 / 0 · 순서 같음 · 정렬 키 중복 0 | 통과 |
| C | 잘라 내기 전 전체 | rev03으로 100,000페이지를 끝까지 넘기며 전체 정렬 커서와 한 행씩 대조 | 페이지 100,000 · 행 1,000,000 · 합계 255,398,623,700 · 어긋난 행 0 | 통과 |
| D | 조인마다 1 쪽 | Q4는 조인이 없다 | — | 해당 없음 (조인 없음) |
| E | 경계 데이터 | 날짜가 바뀌는 92페이지 (12월 30일 3건 + 29일 7건) · 마지막 페이지 | 92페이지 10 = 10 · 2,848,500 · 0 / 0 · 날짜 2종류 / 마지막 10 = 10 · 3,095,700 · 0 / 0 | 통과 |
| F | 계획 해시와 무관하게 대조 | rev02와 rev03은 피킹을 켜면 계획 해시가 같다(3610158121). 그래서 결과로 대조한다 | (B · C) | 통과 |
| G | 변환을 근거로 쓰지 않음 | rev02의 OR-Expansion은 성능 설명에만 썼다. 뜻이 같다는 것은 C로 증명 | — | 통과 |
| H | 영향 분석 | 변경은 Q4 다음 페이지 문장 하나. 인덱스 · 통계 · 파라미터 변경 없음. FORCE와 피킹 끄기를 채택하지 않았으므로 다른 문장에 영향 없음 | — | 통과 |
12. IN 리스트의 길이
"고객을 여러 명 골라 이름을 붙이는" 화면이다. 고르는 수는 1~10명, 요청은 300개다(난수 시드 고정, 길이 분포 1:28 2:31 3:31 4:30 5:38 6:26 7:27 8:37 9:25 10:27). 대상은 M_CUS와 PK_M_CUS다(실습/07).
| 방식 | 문장 (부모) | 하드 파싱 | 요청 한 번 logical reads |
|---|---|---|---|
① 리터럴 IN ('CUS_000123', …) | 300 | 300 · 303 | 12.3 · 12.5 |
② 길이만큼 바인드 IN (:b1 … :bk) | 10 | 40 | 12.3 · 12.7 |
| ③ 10칸 고정 · 남는 칸은 마지막 값 되풀이 | 1 | 4 | 12.3 |
④ 컬렉션 IN (SELECT column_value FROM TABLE(:arr)) | 1 | 1 | 12.8 · 12.9 |
- 바인드로 바꿔도 칸 수가 문장의 일부다. 길이가 열 가지면 문장도 열 개다.
- ②③의 하드 파싱이 문장 수보다 많은 것은
DBMS_SQL이 값을 묶기 전에 파싱하고 실행 때 한 번 더 맞추기 때문이다(§2 TKPROF의Misses during execute). - ③의 계획은
INLIST ITERATOR+INDEX UNIQUE SCAN PK_M_CUS(E-Rows 10)다. 되풀이한 값 때문에 일량이 늘지는 않았다. - ④의 계획은
COLLECTION ITERATOR PICKLER FETCH(E-Rows 5) →SORT UNIQUE→NESTED LOOPS+PK_M_CUS다. 컬렉션의 건수 추정이 계획을 정하므로, 수천 개를 넘기는 화면이라면 다시 재야 한다.
13. ORA-04031
메시지는 "unable to allocate N bytes of shared memory"이다. 공유 풀(또는 large pool 등)에서 필요한 크기의 연속된 메모리를 얻지 못했다는 뜻이다. 오라클은 먼저 붙잡히지 않은(unpinned) 조각을 내보내 자리를 만들고, 그래도 자리를 못 만들 때 이 에러를 낸다.
Free에서 본 것
| 관찰 | 값 |
|---|---|
공유 풀 크기 (V$SGA_DYNAMIC_COMPONENTS · CDB) | 416MB (sga_target 0 · 고정) |
3만 개 하드 파싱 뒤 V$SQL에 남은 [A] 문장 | 308~481 |
| [A] 앞에 올려 둔 이웃 Q1 | [A] 직후 V$SQL에 없음 (두 번 다) |
ORA-04031 | 나지 않았다 |
재현하지 못했다. 3만 개를 넣어도 공유 풀은 에러 대신 밀어내기로 버텼다. 04031이 나려면 내보낼 수 없는 것들(실행 중이거나 열려 있어 붙잡힌 커서, 조각난 큰 요청)로 공유 풀이 막혀야 하는데, 그런 상태를 이 컨테이너 한 대로 만들어 보지는 않았다. 대신 밀려나는 것을 쟀다. 그 대가는 이웃이 다시 하드 파싱을 해야 한다는 점이다. 대규모 EE 환경에서 04031이 터지는 모습은 별권에서 다룬다.
14. 이 화의 측정값 전부
ora-hanorder · Oracle AI Database 26ai Free 23.26.2.0.0 · cpu_count 2 · 공유 풀 416MB · session_cached_cursors 50 · statistics_level TYPICAL (따로 적은 곳 제외). 값이 두 개면 1회차 · 2회차다.
3만 번 클릭 (실습/00)
| [A] 리터럴 | [B] 바인드 | FORCE ([H] 실습/01) | |
|---|---|---|---|
| 걸린 시간 | 36.19 · 36.19초 | 0.67 · 0.67초 | 1.45~1.55초 |
| 하드 파싱 | 30,000~30,431 | 1 · 1 | 329~334 |
| parse time cpu | 3,466~3,493cs | 0 | — |
| CPU | 3,617 · 3,617cs | 67 · 68cs | 145~155cs |
| logical reads | 522,931 · 517,487 | 466,584 · 466,584 | — |
V$SQL에 남은 것 | 308~481 | 1 | 부모 1 · VERSION_COUNT 329 · 10.5MB |
| 이웃 Q1 | 밀려남 · 밀려남 | — | — |
여섯 조각 · FORCE (실습/01 [A][B] · GATHER_PLAN_STATISTICS)
| EXACT | FORCE | |
|---|---|---|
| Q1 rev03 | 91 | 91 |
| Q2 rev01 | 5,869 | 5,868 |
| Q3 rev01 | 840 | 839 |
| Q4 2 · 3 · 100페이지 | 15 | 15 · 15 · 15 (자식 0 · 0 · 1) |
상태 탭 · 최근 1,000건 (실습/01 · 02 · 03)
| 경우 | 완료 | 미결 | 취소 |
|---|---|---|---|
| EXACT | 1,016 (첫 실행 1,016~1,065) | 5,826 · 5,830 | 5,826 |
| 인덱스 계획 (FORCE 완료 먼저 · 바인드 완료 먼저 · 피킹 끔) | 1,016~1,034 | 13,510 | 50,347 |
| FULL 계획 (FORCE 취소 먼저 · 바인드 취소 먼저) | 5,826 | — | 5,826~5,830 |
CURSOR_SHARING_EXACT | 1,016 | — | 5,826 |
ACS (실습/02)
§7의 표를 본다. 100건 화면: 완료 104 · 취소(나쁜 한 번) 8,334 · 이후 5,826 · 선택도 0.018~0.022 · PL/SQL 열어 둔 채 완료 103 · 취소 8,333 ×5 · 파스 호출 1.
피킹 끔 · rev02 / rev03 (실습/03 · 04 · 05)
| 페이지 | 2 | 100 | 1,000 | 10,000 | 100,000 |
|---|---|---|---|---|---|
| rev02 · 피킹 끔 | 1,005,991 | 1,005,006 | 995,954 | 905,413 | 18 |
| rev03 · 피킹 끔 | 14 | 14 | 17 | 16 | 16 |
| rev02 · 피킹 켬 (2페이지에서 태어남 · 마지막에서 태어남) | 14 · 14 | 14 · 14 | 17 · 17 | 16 · 16 | 16 · 16 |
rev03 · 피킹 켬 (실습/04) | 14 | 15 | 17 | 16 | 16 |
DBMS_SQL (실습/05 [5]) · rev02 / rev03 | 1,005,990 / 13 · 19 |
파싱 네 가지 · 네 명 동시 · 부모와 자식 · IN 리스트 · KGL
rev03 정합성
→ §11. 10만 페이지 순회 3.4 · 3.7초.
15. 원천 좌표
전체 지도 →
00_자료준비/06_원서와_공식문서_지도.mdPDF 페이지 = 책 페이지 − 2 ·성능고도화I_목차색인.md(공개 사이트에는 없음)
성능고도화 I권 04장 — 라이브러리 캐시 최적화와 SQL 커서 (주 원천)
| 주제 | 책 페이지 |
|---|---|
| SQL 파싱 · SQL 최적화 · Row-Source Generation | 248 · 249 · 251 |
| 라이브러리 캐시 구조 | 252 |
| 커서란? · 커서 공유 | 257 · 260 |
| Child 커서를 공유하지 못하는 경우 · Parent 커서를 공유하지 못하는 경우 | 264 · 270 |
| 바인드 변수의 중요성 | 272 |
| 바인드 변수의 부작용과 해법 · 적응적 커서 공유 · 입력 값에 따라 SQL 분리 · Literal 상수 값 사용 | 277 · 283 · 286 · 288 |
| 세션 커서 캐싱 · 애플리케이션 커서 캐싱 | 289 · 297 |
| Static SQL vs Dynamic SQL · 문제의 본질은 바인드 변수 사용 여부 | 307 ~ 314 |
| Dynamic SQL 사용 기준 · 선택적 검색 조건 | 315 · 317 ~ 330 |
성능고도화 I권 Ch1 · 그 밖
| 주제 | 권-장 | 책 페이지 |
|---|---|---|
| Shared Pool: 딕셔너리 캐시 · 라이브러리 캐시 | I권 Ch1 | 94 · 96 |
| Library Cache Lock & Pin · 두 직렬화 장치를 따로 두는 이유 | I권 | 493 ~ 498 |
| Cursor_Sharing | I권 | 502 |
오라클 공식문서
| 주제 | 매뉴얼 | 장 제목 |
|---|---|---|
| 커서 공유 · 바인드 피킹 · ACS · CURSOR_SHARING | tgsql | Improving Real-World Performance Through Cursor Sharing |
| SQL 처리 단계 · 하드/소프트 파싱 · 공유 SQL 영역 | tgsql | SQL Processing |
| 공유 풀 · 라이브러리 캐시 구조 | cncpt | Memory Architecture (Shared Pool) |
공유 풀 크기 · ORA-04031 | tgdba | Tuning the Shared Pool and the Large Pool |
CURSOR_SHARING · SESSION_CACHED_CURSORS · OPEN_CURSORS | refrn | Initialization Parameters |
V$SQL · V$SQLAREA · V$SQL_SHARED_CURSOR · V$SQL_CS_HISTOGRAM · V$SQL_CS_SELECTIVITY · V$SQL_BIND_METADATA · V$LIBRARYCACHE | refrn | Dynamic Performance Views |
CURSOR_SHARING_EXACT 힌트 | sqlrf | Comments — Hints |
| 10046 · TKPROF | tgsql | Performing Application Tracing |
이 화에서 꼭 확인할 뷰
sql
V$SQLAREA -- 부모: version_count · executions · loads · force_matching_signature · sharable_mem
V$SQL -- 자식: child_number · is_bind_sensitive · is_bind_aware · is_shareable · plan_hash_value
V$SQL_SHARED_CURSOR -- 자식이 따로 생긴 이유 (Y 칸) · reason (XML)
V$SQL_CS_HISTOGRAM -- ACS 의 세 칸 · V$SQL_CS_SELECTIVITY -- bind aware 자식의 선택도 범위
V$MYSTAT -- parse count (total) · parse count (hard) · session cursor cache hits · parse time cpu
V$SES_OPTIMIZER_ENV -- 내 세션의 cursor_sharing · optimizer_mode · (바꿨다면) _optim_peek_user_binds다음 화가 여기서 가져가는 것
| 화 | 가져가는 것 |
|---|---|
| 12 | 하드 파싱이 3만에서 1이 되면 같은 시간에 더 많은 요청이 처리된다. 그러면 그동안 파싱 뒤에 숨어 있던 줄서기가 데이터 쪽에서 드러난다 |
| 14 | T_ORD_BIG 인덱스 3개(변화 없음)의 적재 비용 청구서 |
| 15 | PL/SQL이 커서를 열어 둔 채 다시 쓸 때: 파스 호출 0의 이득과 ACS를 잃는 대가(§7 [E]) · DBMS_SQL의 값 없는 파싱(§9) |
| 18 | 「SQL 코딩 가이드」 v0.1 → v1.0: 리포트에서 2회 이상 되풀이된 원인만 조문으로 |