Skip to content

01화 · 파고들기 ​

조회용 문서다. 처음 읽을 때는 1_사건.md만으로 충분하다. 여기는 1년 뒤 "그게 뭐였지?" 싶을 때 돌아오는 자리다. 이야기는 빼고 정의와 표, 좌표만 담았다.

찾는 것바로 가기
무대 전체 구조 (ERD · 테이블 설명)§1 무대
블록이 정확히 뭔가§2 블록
Buffers · E-Rows · A-Rows 읽는 법§3 실행계획
측정 도구 3종은 언제 뭘 쓰나§4 계기판
6조각 원문과 「그때의 근거」§5 6조각
SQL 문법이 낯설 때§6 문법
공식 문서 · 원서 좌표§7 좌표

1. 무대 — HANORDER의 두 시스템 ​

컨테이너 ora-hanorder · 계정 sqlp · 테이블 16개. 구 시스템과 신 시스템이 한 계정에 섞여 있다.

구 시스템 (2015~) — 접두어 없는 영어 이름 ​

테이블행수블록성격
DEPT / EMP / SALGRADE4 / 14 / 55 / 5 / 5오라클 고전 SCOTT. 개념 확인용
PRODUCTS2,00021상품
CUSTOMERS20,000138고객. grade A 1% / B 9% / C 90%
ORDERS200,0001,232주문. status COMPLETE 90% / PENDING 8% / CANCEL 2%
인덱스 3개: PK_ORDERS · ORDERS_X_CUST · ORDERS_X_DATE

신 시스템 (2019~, 강도현) — M_ / T_ 접두어 ​

테이블행수블록성격
M_RGN85지역 코드
M_ITM1005상품
M_ITM_PRC_HIS1005가격 이력 (ITM_ID, TO_YMD)
M_CUS2,00028고객. CUS_GD A 1% / B 9% / C 90%. CUS_ID가 문자열이다('CUS_000123')
T_ORD100,000849주문
T_ORD_DET200,000828주문상세 (주문당 1~3건)
T_ITM_EVL5,00043상품평가. M:1:M 조인 폭증(08화)의 씨앗
T_ORD_BIG1,000,0005,923PK도 인덱스도 없다. ORD_ST 90/8/2 · ORD_DT 3년치
T_ORD_STLM100,000622정산 결과 (01화 실습에서 생성)
T_STLM_NUM15채번 (01화 실습에서 생성)

⚠️ M_CUS.CUS_ID는 VARCHAR2, CUSTOMERS.cust_id는 NUMBER다. 두 시스템을 한 쿼리에서 조인하면 안 된다. 두 시스템이 같은 계정에 있는 건 "같은 회사의 구·신 시스템"이라는 설정 때문이다.

ERD — 사람이 추론해서 그린 것 ​

이 그림에 그은 선은 데이터베이스에는 없다. FK가 0개라서 컬럼 이름을 보고 추론해 그었다. Data Modeler로 자동 생성하면 선이 하나도 그려지지 않는다.

mermaid
erDiagram
    M_RGN  ||--o{ M_CUS      : "RGN_ID (FK 없음)"
    M_CUS  ||--o{ T_ORD      : "CUS_ID (FK 없음)"
    M_CUS  ||--o{ T_ORD_BIG  : "CUS_ID (FK 없음)"
    M_CUS  ||--o{ T_ITM_EVL  : "CUS_ID (FK 없음)"
    T_ORD  ||--o{ T_ORD_DET  : "ORD_SEQ (FK 없음)"
    M_ITM  ||--o{ T_ORD_DET  : "ITM_ID (FK 없음)"
    M_ITM  ||--o{ T_ITM_EVL  : "ITM_ID (FK 없음)"
    M_ITM  ||--o{ M_ITM_PRC_HIS : "ITM_ID (FK 없음)"

    M_CUS {
        VARCHAR2 CUS_ID PK "CUS_000123 형식"
        VARCHAR2 CUS_NM "고객명"
        VARCHAR2 RGN_ID "지역"
        VARCHAR2 CUS_GD "등급 A/B/C"
    }
    T_ORD {
        NUMBER ORD_SEQ PK "주문번호"
        VARCHAR2 CUS_ID "고객"
        DATE ORD_DT "주문일시"
        VARCHAR2 ORD_ST "상태"
        NUMBER ORD_AMT "금액"
    }
    T_ORD_DET {
        NUMBER ORD_SEQ PK "주문번호"
        NUMBER ORD_DET_NO PK "순번"
        VARCHAR2 ITM_ID "상품"
        NUMBER ORD_QTY "수량"
    }
    T_ORD_BIG {
        NUMBER ORD_SEQ "PK 없음"
        VARCHAR2 CUS_ID "고객"
        DATE ORD_DT "주문일시"
        VARCHAR2 ORD_ST "상태"
        NUMBER ORD_AMT "금액"
    }
    T_ITM_EVL {
        VARCHAR2 ITM_ID PK "상품"
        NUMBER EVL_LST_NO PK "평가순번"
        VARCHAR2 CUS_ID "작성자"
        NUMBER EVL_PT "평점"
    }

오늘 문제가 되는 경로는 왼쪽 위 삼각형이다. T_ORD_BIG ↔ M_CUS ↔ M_RGN으로 이어지는 길인데, Q2가 정확히 이 길을 탄다.

FK가 0개라서 생기는 일 세 가지 ​

#결과어느 화에서 터지나
1고아 데이터를 못 막는다. 없는 고객의 주문이 들어가도 DB는 통과시킨다—
2옵티마이저가 관계를 모른다. 조인 결과 건수를 제대로 추정하지 못한다10화
3ERD 도구가 선을 못 그린다01화에서 직접 확인
+반대로 FK가 있으면, 인덱스가 없는 FK가 자식 테이블 전체에 락을 건다12화 (ora-newshop)

2. 블록 ​

오라클에는 행을 읽는 기능이 없다. 오직 블록 단위로 읽는다.

항목값
블록 크기8,192 bytes (db_block_size 기본값)
T_ORD_BIG 평균 행 길이37 bytes
블록당 행 수169
검산37 × 169 = 6,253. 남는 1,939 bytes는 블록 헤더 · 행 디렉터리 · PCTFREE 몫
sql
SELECT blocks, num_rows, ROUND(num_rows/blocks) AS rows_per_block, avg_row_len
  FROM user_tables WHERE table_name = 'T_ORD_BIG';

SELECT value AS block_size FROM v$parameter WHERE name = 'db_block_size';

BLOCKS와 Buffers가 다른 이유 ​

통계상 BLOCKS는 5,923인데 전체 스캔의 Buffers는 5,826이다.

  • BLOCKS는 세그먼트에 할당된 블록 수다.
  • 전체 스캔은 HWM(High Water Mark)까지만 읽는다. HWM은 데이터가 실제로 닿아 본 지점이다.

100블록 안팎의 차이는 정상이다. 하지만 둘이 크게 벌어진다면 이상 신호다. 대량으로 삭제한 뒤 빈 껍데기가 남았다는 뜻이고, 05화 「지웠는데 왜 안 빨라지죠」가 바로 그 이야기다.

블록 내부 구조(ITL, 행 디렉터리, 행 이전/연결)는 05화에서 뜯어 본다. 오늘은 "8KB 덩어리, 169건"까지만 알면 된다.


3. 실행계획 한 장 읽기 ​

xplan
------------------------------------------------------------------------------------------
| Id  | Operation           | Name      | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |           |      1 |        |      1 |00:00:00.01 |    5826 |
|   1 |  SORT AGGREGATE     |           |      1 |      1 |      1 |00:00:00.01 |    5826 |
|*  2 |   TABLE ACCESS FULL | T_ORD_BIG |      1 |    333K|  20000 |00:00:00.01 |    5826 |
------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
   2 - filter("ORD_ST"='CANCEL')

읽는 순서 — 맨 아래부터 위로 ​

들여쓰기가 깊은 것부터 실행된다. 위 실행계획은 ② TABLE ACCESS FULL → ① SORT AGGREGATE → ⓪ SELECT STATEMENT 순서로 실행된다.

열 하나씩 ​

열뜻볼 때
Operation무엇을 했나TABLE ACCESS FULL은 처음부터 끝까지 훑었다는 뜻
Starts그 단계가 몇 번 실행됐나1이어야 할 값이 100만이라면, 그게 바로 06화의 사건이다
E-Rows옵티마이저가 예상한 행수통계를 근거로 한 짐작
A-Rows실제로 나온 행수사실
A-Time걸린 시간참고만 한다. 판단 근거로 쓰지 않는다
Buffers논리적 읽기 = 블록을 몇 번 만졌나이 교재의 모든 판단 기준
Reads물리적 읽기 (디스크에서 가져온 블록)캐시가 비어 있으면 올라간다. 그래도 Buffers는 거의 변하지 않는다. 다만 디스크에서 곧장 읽은 블록은 세는 방식이 조금 달라서 몇 블록(0.1% 안팎) 적게 찍힐 수 있다

filter인가 access인가 — 오늘의 핵심 ​

Predicate Information에 찍히는 표기는 두 가지다.

표기뜻비용
access그 조건으로 찾아갔다(인덱스로 위치를 좁힘)읽기 전에 거르므로 싸다
filter일단 다 읽고 나서 버렸다100만 건을 읽고 98만 건을 버린다. 그래서 비싸다

위 실행계획은 filter("ORD_ST"='CANCEL')이다. 2만 건을 얻으려고 100만 건을 읽고 98만 건을 버렸다.

E-Rows vs A-Rows — 16배 오추정 ​

ORD_ST는 값이 3종류지만 90 / 8 / 2로 치우쳐 있다. 옵티마이저는 종류 수만 알고 분포는 모른다.

예상: 1,000,000 ÷ 3 = 333,333   ← "세 종류니까 3분의 1이겠지"
실제:                 20,000     ← CANCEL 은 2%
                      ────────
                      16배 오차
sql
SELECT ORD_ST, COUNT(*),
       ROUND(RATIO_TO_REPORT(COUNT(*)) OVER ()*100, 2) AS pct
  FROM T_ORD_BIG GROUP BY ORD_ST ORDER BY 2 DESC;

이 오차를 고치는 도구가 히스토그램이고, 10화에서 다룬다. ⚠️ 자동 통계 수집이 밤사이 히스토그램을 만들어 버리면 이 오추정이 사라진다. 그럴 때는 00_자료준비/고객사/L1_ora-hanorder/04_상태복구.sql을 돌린다.


4. 계기판 — 도구 4종 ​

#도구무엇을 보나언제
①EXPLAIN PLAN + DBMS_XPLAN.DISPLAY예상 계획 (실행 안 함)돌리기 겁나는 SQL
②/*+ GATHER_PLAN_STATISTICS */ + DISPLAY_CURSOR실제 계획 + 단계별 Buffers·A-Rows주력. SQL 하나를 자세히 볼 때
③SET AUTOTRACE ON계획 + consistent gets가장 간단히 훑어볼 때
④V$SQL여러 SQL을 한 표로6조각을 나란히 비교할 때
sql
-- ① 예상
EXPLAIN PLAN FOR  SELECT ...;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY());

-- ② 실제 (주력)
SET SERVEROUTPUT OFF
SELECT /*+ GATHER_PLAN_STATISTICS */ ...;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST'));

-- ③ 한 번에
SET AUTOTRACE ON
SELECT ...;
SET AUTOTRACE OFF

-- ④ 여러 개 비교
SELECT sql_id, executions, buffer_gets,
       ROUND(buffer_gets / GREATEST(executions,1)) AS buffers_per_exec
  FROM v$sql WHERE sql_text LIKE 'SELECT /* BASE_%';

⚠️ V$SQL의 숫자는 누적이다 ​

같은 SQL을 두 번 돌리면 buffer_gets가 두 배로 보인다. SQL이 느려진 게 아니라 그 커서가 살아 있는 동안 쌓인 총합이라서 그렇다. 그러니 반드시 executions로 나눠서 본다.

📌 성능 뷰를 읽을 때는 이 숫자가 순간값인지, 누적값인지, 구간 델타인지 늘 따져 봐야 한다.V$SQL, V$SYSSTAT, V$MYSTAT은 누적이고, AWR 리포트는 두 스냅샷의 델타다. 16화에서 이 구분이 그대로 다시 나온다.

함정 두 개 ​

함정증상조치
SET SERVEROUTPUT ON인 채로 측정DISPLAY_CURSOR가 엉뚱하게 DBMS_OUTPUT.GET_LINES를 보여 준다측정 전에 항상 SET SERVEROUTPUT OFF
GATHER_PLAN_STATISTICS 누락A-Rows와 Buffers 칸이 비어 있다힌트를 넣거나 ALTER SESSION SET STATISTICS_LEVEL=ALL

권한이 없으면 안 보인다 ​

ORA-01031이 나면 측정 권한이 없는 것이다. 00_측정권한_부여.sql을 실행한 뒤 재접속한다.

오늘 안 켜는 것 ​

V$SESS_TIME_MODEL(시간이 어디로 갔나), V$SYSTEM_EVENT(대기 이벤트), AWR과 ASH는 16~17화에서 켠다. 오늘 켜 봐야 읽는 법을 모르니 숫자만 늘어난다.


5. 6조각 — 강도현이 남긴 것 ​

야간 정산 패키지 PKG_ORD_STLM에 든 여섯 조각이다. 19화 내내 이 여섯 개를 고친다.

ID무엇처음 상태몇 번 고치나
Q1기간별 주문 조회날짜를 TO_CHAR로 문자 변환해 비교3번 (02·03·04화)
Q2고객등급별 매출 집계USE_NL 힌트로 못박음 + 스칼라 서브쿼리06·08화 (07화는 재기만 한다)
Q3상품평가 조인M:1:M 직접 조인으로 매출 중복 합산1번 (08화)
Q4정산 목록 페이징전체 정렬 후 잘라내기 + 총건수 재조회09·11화
P1정산 적재 배치한 건씩 INSERT + 1,000건마다 커밋3번 (13·14·15화)
N1채번MAX(번호)+11번 (12화)

Q1 — 기간별 주문 조회 ​

sql
SELECT COUNT(*), SUM(ORD_AMT)
  FROM T_ORD_BIG
 WHERE TO_CHAR(ORD_DT, 'YYYYMMDD') BETWEEN '20240601' AND '20240630';

그때의 근거: 2019-03-11 hd.kang 커밋 로그 "날짜 타입 비교에서 시분초 때문에 하루가 누락되는 버그 수정. 문자로 통일."

실제 버그를 고친 것이다. ORD_DT에 시분초가 있으면 BETWEEN ... AND DATE '2024-06-30'은 30일 00:00 이후를 놓친다. 방법이 틀렸을 뿐 동기는 옳았다. → 02·03화

Q2 — 고객등급별 매출 집계 (오늘의 범인) ​

sql
SELECT /*+ LEADING(o c) USE_NL(c) */
       c.CUS_GD,
       COUNT(*)                                                       AS ord_cnt,
       SUM(o.ORD_AMT)                                                 AS amt,
       MAX((SELECT r.RGN_NM FROM M_RGN r WHERE r.RGN_ID = c.RGN_ID))  AS rgn_sample
  FROM T_ORD_BIG o, M_CUS c
 WHERE c.CUS_ID = o.CUS_ID
 GROUP BY c.CUS_GD;

문제 두 가지가 겹쳐 있다. ① USE_NL 힌트: 100만 행을 한 건씩 돌며 M_CUS를 찾는다. ② 스칼라 서브쿼리: 지역명을 가져오려고 M_RGN을 다시 조회한다. 서브쿼리를 MAX()로 감싼 것은 GROUP BY 때문이다. 집계하지 않는 컬럼은 그대로 쓸 수 없어서 감쌌을 뿐이고, 이 MAX 자체는 성능과 상관이 없다.

그때의 근거: 2018-11-06 성능개선 회의록"등급별 집계 3초 → USE_NL 적용 후 0.4초. 적용 승인."

그때 데이터는 2만 건이었다. 지금은 100만 건이다. 회의록의 숫자는 사실이었다. 그런데 그때도 블록으로 쟀다면 결론이 같았을까? → 06화

Q3 — 상품평가 조인 (가장 작은데 가장 위험) ​

sql
SELECT d.ITM_ID, SUM(d.ORD_QTY * d.UNT_PRC) AS amt, COUNT(*) AS cnt
  FROM T_ORD_DET d, T_ITM_EVL e
 WHERE e.ITM_ID = d.ITM_ID
 GROUP BY d.ITM_ID ORDER BY amt DESC FETCH FIRST 5 ROWS ONLY;

상품 하나에 평가가 N개면 그 상품 매출이 N번 더해진다. 841블록으로 빠르지만 답이 틀린다. → 08화

Q4 / P1 / N1 ​

sql
-- Q4 : 전체를 정렬해 1,000건까지 자른 뒤, 그중 991~1000만 쓴다
SELECT * FROM (SELECT ROWNUM rn, a.*
                 FROM (SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
                         FROM T_ORD_BIG ORDER BY ORD_DT DESC, ORD_SEQ DESC) a
                WHERE ROWNUM <= 1000)
 WHERE rn > 990;

-- Q4c : 화면 하단의 "총 N건"을 위해 테이블을 한 번 더 읽는다
SELECT COUNT(*) FROM T_ORD_BIG;

Q4 5,826 + Q4c 5,826 = 11,652블록. 테이블 전체가 5,826이니, 10건을 보여 주려고 한 페이지마다 테이블 전체를 두 번 읽는 셈이다. 사용자가 "다음 페이지"를 누를 때마다 이 일이 되풀이된다. → 09화

sql
-- P1 : 커서 FOR 루프 + 1,000건마다 COMMIT
--      근거: "트랜잭션은 짧게 (2020.07 강도현)"
--      ← 12화의 재발방지책이 13화의 장애(ORA-01555)가 된다
-- N1 : SELECT NVL(MAX(STLM_SEQ),0)+1 FROM T_ORD_STLM
--      근거: "시퀀스는 구멍이 생겨서" ← 감사 지적을 받은 적이 있었다
--      채번 테이블 T_STLM_NUM 이 있는데도 쓰지 않는다. LAST_NO 는 0인 채로 남아 있다

원문 전체는 실습/00_관통코드_생성.sql에 있다.


6. 오늘 쓴 SQL 문법 ​

처음 보는 게 있으면 여기서 확인한다. 아래는 모두 조회용 딕셔너리 뷰다. 일반 테이블이 아니라, 오라클이 자기 자신에 관한 정보를 보여 주는 창구다.

쓴 것무엇비고
user_tables내 계정의 테이블 목록 + 통계num_rows·blocks는 통계를 수집한 시점의 값이다. 실시간 값이 아니다
user_constraintsPK · FK · UK · CHECKconstraint_type: P=PK, R=FK(Reference), U=UK
user_cons_columns그 제약이 어느 컬럼에 걸렸나제약명으로 조인한다
user_indexes인덱스 목록uniqueness는 UNIQUE/NONUNIQUE
user_tab_col_statistics컬럼별 통계 · 히스토그램 유무10화에서 주로 쓴다
v$parameter초기화 파라미터db_block_size 등
v$sql공유 풀에 살아 있는 SQL과 그 누적 통계누적이다(§4 참조)
sql
-- DECODE : 값에 따라 다른 것을 반환 (CASE 의 축약형)
DECODE(constraint_type, 'P','PK', 'R','FK', 'U','UK')
--   = CASE constraint_type WHEN 'P' THEN 'PK' WHEN 'R' THEN 'FK' ... END

-- RATIO_TO_REPORT : 전체 합계 대비 비율을 구하는 분석함수
ROUND(RATIO_TO_REPORT(COUNT(*)) OVER () * 100, 2) AS pct
--   OVER () 가 "전체를 한 덩어리로 보라"는 뜻. 비율 계산에 서브쿼리가 필요 없다

-- LISTAGG : 여러 행을 한 줄로 이어 붙인다 (컬럼 목록 만들 때)
LISTAGG(column_name, ',') WITHIN GROUP (ORDER BY position)

-- NULLS LAST : 정렬할 때 NULL 을 맨 뒤로 (통계 없는 테이블이 위로 오는 걸 막는다)
ORDER BY num_rows DESC NULLS LAST

-- FETCH FIRST n ROWS ONLY : 상위 n건 (12c 이상. 옛날 ROWNUM 방식을 대체)

📌 USER_*는 내 것, ALL_*은 내가 볼 수 있는 것, DBA_*는 전부를 보여 준다. 같은 뷰를 범위만 셋으로 나눈 것이다.


7. 원천 좌표 ​

좌표 없이 "다시 읽었다"고만 쓰면 인정하지 않는다. 전체 지도는 00_자료준비/06_원서와_공식문서_지도.md에 있다.

성능고도화 (주 원천) ​

PDF 페이지 = 책 페이지 − 2(I·II 양권 공통). 절별 페이지는 성능고도화I_목차색인.md *(공개 사이트에는 없음)*에 있다.

주제권-장-절책 p.
오라클 성능관리 전반I권 03장153
Explain planI권 03장 01절155
AutoTraceI권 03장 02절—
SQL 트레이스I권 03장 03절—
DBMS_XPLAN 패키지I권 03장 04절—
V$SYSSTATI권 03장 05절—
Response Time Analysis (OWI)I권 03장 07절—

오라클 공식문서 ​

주제매뉴얼장 제목관련 뷰
성능 개선 방법론tgdba 3장Performance Improvement Methods—
Time Model · 대기이벤트tgdba 5장Measuring Database PerformanceV$SESS_TIME_MODEL
SQL 튜닝 접근법tgsqlSQL Performance Methodology—
실행계획 표시arplsDBMS_XPLANDISPLAY_CURSOR
논리적 읽기의 정의cncptMemory Architecture > Database Buffer CacheV$BH
링크 규칙: https://docs.oracle.com/en/database/oracle/oracle-database/{19|26}/{약칭}/{페이지}.html

오늘 읽으면 가장 남는 한 장 ​

tgdba 3장 Performance Improvement Methods. 오늘 선배가 한 말("증언이 아니라 측정", "무엇을, 어떤 조건에서, 어떤 단위로 쟀는지")이 모두 이 장에 공식 용어로 적혀 있다. 이 교재 커리큘럼의 뼈대이기도 하다(3장 → 5장 → 6장 → 9장 → 7장 → 10장).


다음 화가 여기서 가져가는 것 ​

화기준선무엇을 하나
02Q1 = 5,827인덱스를 만들고 안 타는 걸 확인한 뒤 SQL을 고친다
0402화에서 만든 인덱스커피 캐리어가 다시 나온다. 같은 인덱스인데 왜 하나만 느린가
06Q2 = 1,026,849힌트를 떼면 5,869, 175분의 1로 줄어든다
08Q3의 정합성 오류중복 합산을 고친다
09Q4+Q4c ≈ 11,652페이징 재설계
10E-Rows 333K vs A-Rows 20,000히스토그램으로 오추정을 고친다
12FK 0개 · N1의 MAX+1FK가 있는 시스템과 비교한다
13P1의 커밋 100회redo size와 ORA-01555
18W01 원장 전체6조각을 전부 재측정해 나란히 놓는다