Skip to content

11화 · 같은 SQL이 수만 개 ​

5월 13일 월요일. 이번 주에는 조각 하나가 아니라 여섯 조각이 함께 사는 곳, 공유 풀을 연다. 인프라팀은 "CURSOR_SHARING=FORCE로 한 방에" 정리하자고 한다. 범인으로 지목된 문장은 내가 2주 전에 넘긴 rev02다.


오전 9시 00분 ​

박팀장  08:47
인프라팀에서 공유 풀 경보 건으로 연락이 왔습니다.
지난주 목요일부터 '하드 파싱' 경보가 매일 오전에 울린답니다.
모양이 거의 같은 SQL 이 끝도 없이 들어왔다 나간다고 하고요.
인프라팀 의견은 CURSOR_SHARING 을 FORCE 로 바꾸면 한 번에 정리된다는 겁니다.
오늘 밤 반영하겠다는데, 튜닝 쪽 의견이 있으면 오후 4시까지 달라고 합니다.

CURSOR_SHARING이라는 단어를 메모장에 적었다. 처음 보는 파라미터였다. 그런데 문장 모양은 낯익었다. "○○를 바꾸면 한 번에." 2주 전에는 그 자리에 PGA가 있었다.

선배가 커피를 들고 와서 화면을 봤다. "이번엔 공유 풀이네."

"FORCE가 뭔데요?"

"서버가 SQL 안의 상수를 전부 바인드 변수로 바꿔 끼우는 스위치야." 선배가 의자를 끌어왔다. "켜면 정말 한 번에 정리될 수도 있어. 근데 뭐가 정리되고 뭐가 망가지는지는 재 봐야 알지. 4시까지면 시간은 있어."

"그럼 어디부터 열어요? 오늘은 조각이 없는데요."

"있어. 공유 풀에 사는 건 SQL이야. 여섯 조각이 전부 거기 살아." 선배가 말했다. "매주 하던 대로 시작해."


오전 9시 20분 — 내 문장이었다 ​

리비전 폴더를 열었다. 지금 살아 있는 리비전은 넷이었다.

Q1_rev03.sql   기간별 주문 조회   — 조회월 :p_ym 바인드 (03화)
Q2_rev01.sql   고객등급별 집계    — 입력값 없음 (06화)
Q3_rev01.sql   상품평가 조인      — 입력값 없음 (08화)
Q4_rev02.sql   정산 목록          — 포스트잇 :p_dt, :p_seq 바인드 (09화)

그리고 인프라팀이 박팀장에게 붙여 보낸 캡처를 열었다. 경보가 울렸을 때 공유 풀에 가장 많던 문장의 견본 두 줄이었다.

sql
... FROM T_ORD_BIG WHERE ORD_DT <= DATE '2024-12-30' AND (ORD_DT < DATE '2024-12-30' OR ORD_SEQ <= 988784) ...
... FROM T_ORD_BIG WHERE ORD_DT <= DATE '2024-12-29' AND (ORD_DT < DATE '2024-12-29' OR ORD_SEQ <= 915418) ...

한눈에 알아봤다. Q4 rev02. 포스트잇이었다. 2주 전 월요일에 조민우 씨와 합의하고 내 손으로 개발팀에 넘긴 문장이었다.

그런데 내 파일에는 :p_dt와 :p_seq라고 적혀 있었는데, 캡처에는 날짜와 번호가 글자로 박혀 있었다. 개발팀이 반영한 소스를 받아 봤다.

java
String sql = "SELECT ... FROM T_ORD_BIG WHERE ORD_DT <= DATE '" + postitDate
           + "' AND (ORD_DT < DATE '" + postitDate + "' OR ORD_SEQ <= " + postitSeq + ") ...";

포스트잇 값을 문자열로 이어 붙이고 있었다. "다음"을 누를 때마다 처음 보는 문장이 하나씩 생긴다.

"왜 이렇게 했을까요? 파일엔 바인드라고 써 놨는데."

선배가 내 09화 실습 폴더의 03_포스트잇.sql을 가리켰다. 그 스크립트는 SQL*Plus에서 돌리려고 포스트잇 값을 치환 변수(&p_dt)로 박아 넣고 있었다. 머리말에 "실제 화면은 바인드 변수로 넘긴다. 여기서는 흉내 낸다"라고 적어 두기는 했다.

"개발팀은 주석이 아니라 돌아가는 코드를 베껴." 선배가 말했다. "네 실습 스크립트가 견본이 된 거야."

그리고 하나가 더 떠올랐다. T1 7호의 「리스크」 칸이었다.

- 포스트잇 값을 문자열로 붙이면 페이지마다 다른 SQL 이 된다 → 11화

적어 두기만 했다. 누구에게도 확인해 달라고 하지 않았다.


오전 9시 50분 — 3만 번 넘겨 본다 ​

운영 DB에서는 실험하지 않는다(T0 제6조). 그래서 연습 DB에서 그 일주일을 재현하기로 했다. 정산 목록을 1페이지부터 3만 페이지까지, 개발팀 방식 그대로 포스트잇을 문자열로 이어 붙여서 3만 번 넘긴다.

재기 전에 이웃을 하나 올려 뒀다. Q1 rev03을 한 번 돌려서 공유 풀에 들어 있는 것까지 확인했다.

[A] 클릭 30000번 · 걸린 시간 36.19초
    parse count (total)        30,802
    parse count (hard)         30,431
    parse time cpu              3,466
    CPU used by this session    3,617
    session logical reads     522,931

36초가 걸렸다. 3만 번이니 한 번에 1.2밀리초 남짓이다. 화면 한 장으로 치면 사람이 느낄 수도 없는 시간이었다.

"한 번에 1밀리초면 문제없는 것 같은데요. Buffers도 09화에서 잰 그대로 15블록이고요."

"parse time cpu를 읽어 봐." 선배가 말했다. 단위는 100분의 1초다. 3,466, 곧 34.7초였다. CPU 전체는 3,617, 즉 36.2초였다.

"…36초 중에 35초를 파싱에 썼어요."

"데이터를 읽는 데 쓴 게 아니라 어떻게 읽을지 정하는 데 쓴 거야. 그것도 3만 번을. 매번 처음 보는 문장이니까." 선배가 화면의 parse count (hard)를 짚었다. "그게 하드 파싱이야. 경보가 센 것도 이거고."

그다음에는 공유 풀을 봤다. 3만 개를 넣었으니 3만 줄이 나올 줄 알았다.

  IN_V_SQL  FMS_KINDS      EXECS         MB  AVG_BYTES
       308          1        308        9.4      32040

308개. 다시 돌렸을 때는 404개였다. 3만 개 중 몇백 개만 남아 있었다. 그리고 아까 올려 둔 이웃 Q1은 없었다. V$SQL에서 사라지고 없었다.

"어디 갔어요?"

"밀려났지. 공유 풀은 커서를 다 쥐고 있지 않아. 자리가 모자라면 오래 안 쓴 것부터 내보내." 선배가 말했다. "그러니까 '같은 SQL이 수만 개'라는 말은 반만 맞아. 수만 개가 쌓여 있는 게 아니라 수만 개가 지나갔어. 들어올 때마다 하드 파싱을 하고, 나갈 때는 남의 커서까지 데리고 나가. Q1은 아무 잘못도 없이, 다음에 돌 때 하드 파싱을 다시 해야 해."

FMS_KINDS 열은 1이었다. FORCE_MATCHING_SIGNATURE는 리터럴을 지웠을 때 같은 문장인지를 가리는 지문이다. 308개가 전부 같은 지문이었다. 리터럴만 다를 뿐 사실상 하나의 문장이었다.


오전 10시 30분 — 서식함 ​

선배가 일어섰다. "따라와."

선배를 따라간 곳은 복합기 옆이었다. 벽에 붙은 철제 캐비닛, 결재 서식함이었다. 칸마다 지출결의서, 출장신청서, 휴가신청서, 비품요청서 같은 양식이 꽂혀 있었다.

선배가 지출결의서를 한 장 뽑았다. 위에는 제목이, 가운데에는 금액 [ ] 사용일 [ ] 같은 빈칸이, 아래에는 결재선이 인쇄돼 있었다. 담당 → 팀장 → 본부장.

"이 양식, 누가 만들었을까?"

"총무팀이요."

"새 양식 하나 만들려면 총무팀이 뭘 해야 해?"

생각나는 대로 말했다. 무슨 문서인지 문구를 확인하고, 규정을 찾아 누가 결재할지 정하고, 그 결재선을 넣어 인쇄하고, 서식함에 칸을 하나 만들어 꽂는다.

"그게 하드 파싱이야." 선배가 양식을 흔들었다. "문법 확인, 의미 확인, 결재선 정하기가 최적화, 인쇄가 실행계획 만들기. 제일 비싼 건 결재선 정하기고."

"그럼 개발팀 방식은…"

"매번 총무팀에 새 양식을 주문하는 거지. '출장비 52,300원 지출결의서' 양식, '출장비 52,400원 지출결의서' 양식. 금액을 빈칸에 쓰는 게 아니라 제목에 인쇄해서." 선배가 서식함을 두드렸다. "그럼 서식함이 거의 같은 양식으로 넘치고, 총무팀은 자리를 만들려고 오래된 걸 버려. 그러다 네 휴가신청서도 버려. 그게 아까 Q1이야."

"빈칸 있는 양식 하나를 계속 쓰면 되는 거네요. 그게 바인드고."

"그래. 그런데 빈칸 양식을 쓰는 방법도 여러 가지야." 선배가 손가락을 폈다.

"하나. 총무팀에 새로 주문한다. 이게 하드 파싱이야. 둘. 서식함까지 걸어와서 칸을 찾아 꺼낸다. 소프트 파싱. 결재선은 이미 정해져 있으니 그 단계는 건너뛰지. 셋. 자주 쓰는 양식은 어느 칸에 있는지 수첩에 적어 둔다. 세션 커서 캐시. 서식함 앞에서 찾는 시간까지 건너뛰어. 넷. 아예 양식을 책상 위에 펼쳐 두고 빈칸만 지워 가며 다시 쓴다. 커서를 열어 둔 채 재실행. 서식함까지 가지도 않아."

자리로 돌아와 같은 2,000번 클릭을 네 가지 방식으로 불러 봤다. DBMS_SQL로 파스 호출을 직접 제어하면 네 경우를 따로 만들 수 있다.

같은 2,000번 클릭파스 호출하드 파싱세션 캐시 적중CPU (1/100초)
① 하드: 리터럴 새 문장2,0592,034—163
② 소프트: 바인드, 캐시 끔2,008407
③ 세션 캐시: 바인드, 캐시 켬2,00142,0046
④ 열어 둔 채23—5

하드 파싱만 다른 세상이었다. 나머지 셋은 여기서는 거의 구별되지 않았다.

※ 이 표와 아래 트레이스의 바인드 문장은 오후에 만든 rev03이다. 아침에 rev02로 처음 이 실험을 짰을 때는 바인드 쪽 클릭 한 번이 100만 블록을 넘어 실험이 끝나지 않았다. 그 사연은 오후 3시 40분에 나온다.

"소프트랑 세션 캐시가 똑같은데요."

"혼자 쓰니까. 서식함 앞에 줄이 없으면 찾는 시간이 아깝지 않아." 선배가 말했다. "줄이 생기면 달라져. 이따 네 명을 동시에 세워 볼 거야."

트레이스로도 확인했다. 10046을 켜고 20번씩 부른 뒤 TKPROF로 읽었다. 리터럴 쪽은 문장이 20개라 표도 20개였고, 표마다 Parse 1 아래에 Misses in library cache during parse: 1이 찍혀 있었다. 바인드 쪽은 표 하나에 Parse 20 · Execute 20이었고 Misses는 1이었다. 열어 둔 쪽은 Parse 1 · Execute 20이었다.

Misses가 곧 하드 파싱이다. 서식함에 없어서 총무팀까지 간 횟수다.


오전 11시 10분 — 바인드로 3만 번 ​

이번에는 rev02 파일에 적힌 그대로, 포스트잇을 바인드 변수로 넘기면서 같은 3만 번을 다시 넘겼다.

[B] 클릭 30000번 · 걸린 시간 0.67초
    parse count (hard)              1
    CPU used by this session       67
    session logical reads     466,584

36.19초 → 0.67초. 하드 파싱 30,431 → 1. 공유 풀에는 문장이 하나, 실행 횟수는 30,000이었다.

session logical reads도 522,931에서 466,584로 줄어 있었다. 한 번에 17.4블록과 15.6블록이다. 하드 파싱도 딕셔너리를 뒤지느라 블록을 읽기 때문이다.

"이제 네 명." 선배가 말했다. 07화에서 쓴 방식 그대로, 터미널 하나에서 일꾼 넷을 동시에 띄웠다. 리터럴 일꾼은 각자 3,000번, 바인드 일꾼은 각자 20,000번을 돌렸다. 바인드는 너무 빨리 끝나서 많이 시켜야 뭐라도 보였다. 비교는 클릭 1,000번 기준으로 나눠서 했다.

네 명 동시 · 클릭 1,000번당 (두 번 잰 범위)
                  걸린 시간      CPU         공유 풀 자물쇠 대기 (Concurrency)
  리터럴 일꾼      171~205 cs    136 cs      9.6~11.3 cs
  바인드 일꾼      5.1~5.4 cs    3.4~3.6 cs  0.11~0.15 cs

※ 바인드 일꾼은 클릭마다 커서를 열고 닫는 화면 프로그램을 흉내 내느라 DBMS_SQL을 썼고, 문장은 오후에 만든 rev03이다. rev02를 그렇게 부르면 클릭 한 번이 100만 블록을 넘는다(오후 3시 40분에 나온다).

리터럴 일꾼들의 대기 목록 맨 위에는 latch: shared pool이 있었다. 일꾼 하나가 3,000번 클릭하는 동안 23~25cs를 기다렸다. 서식함에 새 칸을 만들려면 캐비닛 문을 잡아야 하는데, 그 문은 한 번에 한 사람만 잡을 수 있다.

"이 정도면 된 거 아니에요?" 나는 박팀장 메시지를 다시 열었다. "개발팀이 바인드로 고칠 때까지는 시간이 걸리잖아요. 그동안 FORCE를 켜 두면 서버가 알아서 바인드로 바꿔 주니까, 지금 잰 숫자가 그대로 나오는 거죠?"

"그럼 재 봐. 네 세션에만 켜고." 선배가 말했다. "그리고 여섯 조각 전부 다."


오전 11시 40분 — FORCE를 내 세션에만 ​

sql
ALTER SESSION SET cursor_sharing = FORCE;

시스템 전체가 아니라 내 세션에만 켰다. 끝나면 EXACT로 되돌리고 확인한다.

먼저 여섯 조각 중 SQL 넷을 EXACT와 FORCE로 한 번씩 돌렸다. Q4는 개발팀이 보낸 문장을 그대로 썼다.

               EXACT     FORCE    계획
  Q1 rev03        91        91    같음
  Q2 rev01     5,869     5,868    같음
  Q3 rev01       840       839    같음
  Q4 (개발팀)     15        15    같음

계획은 넷 다 그대로였다. FORCE가 무엇을 바꿔 끼웠는지 V$SQL에서 텍스트를 확인했다.

Q1  ... TO_DATE(:p_ym||:"SYS_B_0",:"SYS_B_1") ... ADD_MONTHS(..., :"SYS_B_4")
Q3  ... EXISTS (SELECT :"SYS_B_0" FROM T_ITM_EVL ...) ... FETCH FIRST :"SYS_B_1" ROWS ONLY
Q4  ... ORD_DT <= DATE :"SYS_B_0" AND (ORD_DT < DATE :"SYS_B_1" OR ORD_SEQ <= :"SYS_B_2") ... ROWNUM <= :"SYS_B_3"

Q1의 '01'과 'YYYYMMDD', Q3의 SELECT 1과 FETCH FIRST 5까지 전부 바인드로 바뀌어 있었다. Q2는 상수가 없어서 그대로였다.

"봐요. 계획은 하나도 안 바뀌었어요."

"Q4는 몇 개가 됐어?"

같은 날짜의 두 페이지(2페이지, 3페이지)와 다른 날짜의 한 페이지(100페이지)를 열어 본 참이었다. 부모 커서는 하나였는데 자식이 둘이었다. 2페이지와 3페이지는 자식 0을 같이 썼고, 100페이지는 자식 1을 새로 만들었다. 왜 따로 만들었는지 V$SQL_SHARED_CURSOR를 열어 보니 자식 1 줄에 Y가 하나 있었다.

child 1 : HASH_MATCH_FAILED = Y

"DATE '...' 리터럴이야." 선배가 말했다. "FORCE는 그걸 바인드 자리로 바꾸긴 하는데, 안전하지 않은 리터럴로 다뤄. 값이 다르면 자식을 따로 만들어. 날짜마다 하나씩."

그럼 아까 그 3만 번은 어떨까? FORCE를 켠 채로 다시 돌렸다.

[H] 클릭 30000번 · 1.45초 · 하드 파싱 329 · 서로 다른 날짜 329일
    VERSION_COUNT 329 · 부모 하나가 10.5 MB

하드 파싱은 30,431에서 329로 줄었다. 시간도 36초에서 1.5초로 줄었다. FORCE는 효과가 있었다. 다만 바인드로 쟀던 0.67초, 하드 파싱 1번까지는 못 갔다. 날짜 하나에 자식이 하나씩 생겨, 부모 아래에 자식이 329개 매달렸다.

"반쯤 됐네요." 내가 말했다. "그래도 30,000이 329면 오늘 밤 켜 두는 것도…"

"공유 풀에 여섯 조각만 사는 게 아니야." 선배가 내 말을 끊었다. "FORCE는 서버 전체 스위치야. 일부러 리터럴로 쓴 문장도 바꿔. 그런 게 있는지 찾아봐."


오후 1시 30분 — 일부러 박은 리터럴 ​

점심을 먹고 V$SQL에서 상수가 박힌 채로 반복해서 도는 문장을 찾았다. 정산팀 화면 소스에 그런 문장이 셋 있었다. 정산 상태 확인 화면이었다. 탭이 완료, 미결, 취소 셋이고, 탭마다 최근 1,000건을 보여 준다.

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;
2019-06-17  hd.kang
  정산 상태 확인 화면: 탭별로 SQL 을 따로 둔다 (완료 / 미결 / 취소). 상태값은 탭마다 상수.
  표시 상한 1,000건 — 정산팀 요청.

문장은 탭마다 하나씩 모두 셋이었다('COMPLETE', 'PENDING', 'CANCEL'). 상태 컬럼 ORD_ST는 01화부터 알던 바로 그 컬럼이고, 비율은 90 / 8 / 2다. 그리고 지난주에 이 컬럼에 히스토그램을 붙였다. 이제 오라클은 셋의 비율을 안다.

탭을 하나씩 열어 쟀다.

  완료 탭   인덱스를 거꾸로 (INDEX FULL SCAN DESCENDING)    1,016
  미결 탭   테이블 전체 + Top-N 정렬                         5,826
  취소 탭   테이블 전체 + Top-N 정렬                         5,826

셋이 서로 다른 길을 탔다. 완료는 열 건에 아홉 건이 완료라서 최근 순 인덱스를 따라 내려가면 1,000건이 금방 찬다. 취소는 50건에 한 건꼴이라 인덱스로 가면 한참을 내려가야 한다. 그러느니 테이블을 한 번 다 읽고 정렬하는 편이 싸다. 히스토그램이 있으니 오라클도 그걸 알고 고른 것이다.

"FORCE를 켜면 이 셋이 어떻게 될 것 같아?" 선배가 물었다.

"하나가 되겠죠. ORD_ST = :"SYS_B_0"." 그러다 아침에 서식함 앞에서 들은 말이 떠올랐다. 결재선은 양식을 처음 만들 때 정한다. "…그 하나의 계획은 누가 정해요?"

"그날 첫 손님." 선배가 말했다. "오라클은 하드 파싱할 때 바인드 변수에 들어 있는 값을 엿봐. 그게 바인드 피킹이야. 첫 손님이 완료 탭을 열었으면 완료에 맞는 길로 양식을 찍어. 그다음 손님이 취소 탭을 열어도 그 양식으로 처리해."

FORCE를 켜고 두 경우를 재 봤다. 월요일 아침 첫 손님이 완료 탭을 연 날과 취소 탭을 연 날이다.

FORCE 아래 · 최근 1,000건완료 탭미결 탭취소 탭
EXACT (탭마다 문장 따로)1,0165,8265,826
첫 손님이 완료 탭1,01613,51050,347 (×3번 모두)
첫 손님이 취소 탭5,826 (×3번 모두)—5,826

완료가 먼저 열린 날에는 취소 탭이 5,826에서 50,347로, 8.6배가 됐다. 세 번 열면 세 번 다 그랬다. 취소가 먼저 열린 날에는 완료 탭이 1,016에서 5,826으로, 5.7배가 됐다.

"어느 쪽이든 누군가는 손해를 봐." 선배가 말했다. "강도현 씨가 이걸 알고 탭마다 상수로 썼는지는 몰라. 커밋에 이유가 안 적혀 있으니까. 다만 이 문장들이 리터럴이었으니까 지난주에 히스토그램을 붙이자마자 셋이 각자 맞는 길을 찾은 거야. FORCE는 그걸 다시 하나로 묶어 버려."


오후 2시 10분 — 알아서 고쳐 준다며 ​

오전에 읽어 둔 게 있었다. "그런데 선배님, 요즘 버전엔 적응적 커서 공유(ACS)가 있잖아요. 값마다 일량이 크게 다르면 오라클이 알아채고 자식을 나눈다던데요. 그럼 첫 손님이 틀려도 몇 번 지나면 고쳐지는 거 아니에요?"

"재 봐."

이번엔 FORCE 대신 처음부터 바인드로 짰다(VAR st VARCHAR2(10)). 완료를 먼저 열고, 취소를 네 번 열었다.

ST          CH  PLAN_HASH    BUFFERS
COMPLETE     0  369784633       1,034
CANCEL       0  369784633      50,347
CANCEL       0  369784633      50,347
CANCEL       0  369784633      50,347
CANCEL       0  369784633      50,347

CH  EX  BS  BA  CS_HISTOGRAM
 0   5   Y   N  0:0  1:5  2:0

네 번 다 50,347이었다. 자식은 끝까지 0번 하나뿐이었다. IS_BIND_SENSITIVE는 Y였다. 오라클은 이 문장이 값에 따라 갈릴 수 있다는 걸 알고 있었다. 그런데 IS_BIND_AWARE는 N이었다. 고칠 생각은 하지 않은 것이다.

"왜죠? 1,034하고 50,347이면 50배 차이인데요."

"CS_HISTOGRAM을 봐." 선배가 말했다. "오라클은 실행할 때마다 일량을 0번, 1번, 2번 칸 세 개에 나눠서 세. 서로 다른 칸에 든 실행이 섞여야 '이상하다'고 알아채. 그런데 완료 한 번, 취소 네 번이 전부 1번 칸에 들어갔어. 오라클 눈에는 다 비슷한 일이야."

같은 화면을 최근 100건으로 바꿔 다시 쟀다.

COMPLETE     0  369784633         104
CANCEL       0  369784633       8,334      ← 나쁜 한 번
CANCEL       1  654798247       5,826      ← 자식 1 (BA = Y)
CANCEL       1  654798247       5,826

이번엔 고쳐졌다. 완료 104는 0번 칸에, 취소 8,334는 1번 칸에 들어갔다. 칸이 갈리자 다음 실행에서 자식 1이 새로 생겼고, 취소 값을 다시 엿봐서 FULL 계획을 골랐다. V$SQL_SHARED_CURSOR의 자식 1 줄에는 BIND_EQUIV_FAILURE = Y가, V$SQL_CS_SELECTIVITY에는 자식 1이 맡은 값의 범위(선택도 0.018~0.022, 취소 비율 2% 언저리)가 적혀 있었다.

"됐네요. 한 번만 나쁘고."

"그 한 번은 누군가의 화면이야." 선배가 말했다. "그리고 1,000건 화면에서는 그 한 번조차 안 와. 영원히 50,347이야."

사실 이 사이에 한 번 크게 틀렸었다. 처음 쟀을 때는 실행계획마다 줄별 Buffers를 보려고 습관대로 statistics_level = ALL을 켜 놓고 있었다. 그 상태에서 취소를 먼저 연 날을 쟀더니 완료 탭이 두 번째부터 1,016으로 돌아왔다. ACS가 고친 것이다. 나는 그 결과를 들고 선배에게 갔었다.

"고쳐지는데요?"

선배는 내 스크립트 첫 줄을 봤다. "statistics_level 뭐로 놓고 쟀어?"

"ALL이요. DISPLAY_CURSOR 쓰려고…"

"운영은?"

운영은 TYPICAL이었다. TYPICAL로 놓고 같은 순서로 다시 쟀다. 완료 탭 넷이 전부 5,826이었다. 고쳐지지 않았다. CS_HISTOGRAM을 나란히 놓자 차이가 보였다. ALL에서는 완료(FULL) 실행이 2번 칸에 들어가 있었고, TYPICAL에서는 1번 칸이었다. 줄마다 행 수를 세는 장치를 켜자 ACS가 세는 일량도 달라진 것이다.

"재는 도구가 재는 대상을 바꿨어." 선배가 말했다. "T0에 이런 조항은 없어. 그런데 오늘부터 있는 걸로 해. 측정 조건은 운영과 같게. 다르면 다르다고 적고."

💡 그래서 이 화의 실습은 statistics_level을 TYPICAL로 두고, 한 번 실행한 Buffers는 V$SQL.BUFFER_GETS가 늘어난 양으로 잰다. ALL과의 차이는 실습/02의 [D]에서 직접 확인한다.


⛳ 오늘은 여기까지(중단 지점 1) 여기까지가 오늘의 절반이다. 하드 파싱이 왜 비싼지, FORCE가 무엇을 고치고 무엇을 망가뜨리는지 봤다. 이어서 읽으면 인프라팀이 두 번째 안을 낸다. 그리고 09화에서 15블록으로 만든 화면이 100만 블록이 된다.


오후 3시 00분 — 두 번째 안 ​

4시 마감 전에 박팀장에게 중간 보고를 보냈다. "FORCE는 Q4의 하드 파싱을 30,431에서 329로 줄이지만, 정산 상태 확인 화면의 취소 탭이 8.6배 나빠질 수 있습니다. 개발팀이 Q4를 바인드로 고치는 것이 근본 대책입니다."

답이 금방 왔다.

박팀장  14:52
인프라팀과 얘기했습니다. FORCE 는 보류하겠답니다.
개발팀은 이번 주 안에 정산 목록을 바인드로 고치겠다고 하고요.
그런데 인프라팀이 하나 더 묻습니다.
"바인드 피킹이 말썽이라면 아예 피킹을 끄면 되지 않나.
 _optim_peek_user_binds 를 FALSE 로 두는 사이트가 많다"고 합니다.
이것도 의견 주세요.

"피킹을 끄면 첫 손님 문제가 없어지잖아요." 내가 말했다. "엿보지 않으니 누가 먼저 열든 같은 계획이고."

"어떤 계획?"

"…값을 모르니까, 평균적인 계획이요?"

"재 봐. 밑줄로 시작하는 숨은 파라미터야. 운영에선 오라클 지원 없이는 안 건드려. 네 세션에서만 재고, 바로 되돌려." 선배가 말했다. "그리고 상태 탭만 재지 마. 개발팀이 이번 주에 고칠 문장도 재. rev02 파일 그대로, 바인드로."

먼저 상태 탭부터 쟀다. 피킹을 끄자 셋이 전부 인덱스 계획을 탔다. 완료 1,017, 미결 13,510, 취소 50,347이었다. 값을 모르니 오라클은 셋 중 하나쯤 되겠거니 하고 계획을 세웠고, 이 문장에서는 그게 인덱스 쪽이었다. 첫 손님이 누구든 취소 탭은 늘 50,347이다.

그다음은 정산 목록이었다. rev02 파일의 그 문장에 포스트잇을 진짜 바인드 변수로 넘겼다. 2페이지부터 시작했다.

rev02      2페이지  2024-12-30/988784   행 11  Buffers 1,005,991  0.56초
rev02    100페이지  2024-12-29/915418   행 11  Buffers 1,005,006  0.51초
rev02   1000페이지  2024-12-20/58024    행 11  Buffers   995,954  0.51초
rev02  10000페이지  2024-09-12/481690   행 11  Buffers   905,413  0.47초
rev02 100000페이지  2022-01-01/10950    행 10  Buffers        18  0.00초

숫자를 세 번 봤다.

2페이지가 1,005,991블록이었다.

09화에서 내가 15블록으로 만든 화면이었다. 피킹 스위치 하나에 6만 7천 배가 돼 있었다. 마지막 페이지만 멀쩡했다.

실행계획을 띄웠다.

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     |
|   8 |       TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG         |
|*  9 |        INDEX RANGE SCAN                  | IX_ORD_BIG_DT_SEQ |

      OPT_PARAM('_optim_peek_user_binds' 'false')
      OR_EXPAND(@"SEL$2" (1) (2))

   7 - access("ORD_DT"<=:P_DT)  filter("ORD_DT"<:P_DT)
   9 - access("ORD_DT"<=:P_DT AND "ORD_SEQ"<=:P_SEQ)  filter(("ORD_SEQ"<=:P_SEQ AND LNNVL("ORD_DT"<:P_DT)))

VW_ORE_라는 이름표가 보였다. 08화 때 2_파고들기의 변환 도감에 적어 둔 바로 그 이름이었다. OR-Expansion이다.

"OR를 두 갈래로 펼쳤어요." 나는 천천히 읽었다. "ORD_DT < :p_dt 갈래 하나, ORD_DT = :p_dt AND ORD_SEQ <= :p_seq 갈래 하나. 둘을 따로 읽어서 합치고, 그다음에 정렬해서 11건을 잘라요."

"첫 갈래가 2페이지에서 몇 건이야?"

포스트잇 날짜가 12월 30일이니, 그보다 이른 날의 주문 전부다. 100만 건 가까이 된다. 그걸 테이블까지 가서 다 꺼내고, 정렬한 뒤 11건만 남긴다.

"값을 알았으면 이렇게 안 했겠지." 선배가 말했다. "12월 30일보다 이른 행이 거의 전부라는 걸 엿봤으면, 펼치지 않고 인덱스를 거꾸로 내려가다 11건에서 멈췄을 거야. 값을 모르니 두 갈래 중 어느 게 큰지 몰라서, 둘 다 펼쳐 놓고 합친 거야."

확인해 보고 싶었다. 피킹을 다시 켜고, 같은 rev02를 두 번 새로 태어나게 했다. 한 번은 2페이지 값으로, 한 번은 마지막 페이지 값으로 처음 파싱되게 했다.

D1  (2페이지에서 태어남)       2페이지 14 · 100페이지 14 · 1,000페이지 17 · 10,000페이지 16 · 마지막 16
D2  (마지막 페이지에서 태어남)  마지막 16 · 10,000페이지 16 · 1,000페이지 17 · 100페이지 14 · 2페이지 14

피킹이 켜져 있으면 어디서 태어나든 멀쩡했다. 둘 다 인덱스를 거꾸로 내려가는 같은 계획이었다. rev02는 피킹이 꺼지는 날에만 터지는 문장이었다.

"08화의 OR-Expansion하고는 다르네요." 나는 08화 실습을 다시 돌려 봤다. 구 시스템 ORDERS에 건 cust_id = NVL(:c, cust_id) 조건이다.

:c = 123   인덱스 갈래 Starts 1 · FULL 갈래 Starts 0     13
:c = NULL  인덱스 갈래 Starts 0 · FULL 갈래 Starts 1  1,205
           계획 해시는 둘 다 593186481

"그쪽은 두 갈래를 계획 안에 다 넣어 두고, 실행할 때 :C IS NULL 필터가 어느 쪽을 탈지 골라요. 값이 바뀌어도 계획은 하나인데 길은 둘이에요."

"그래. 08화가 넘긴 '바인드 값에 따라 갈리는 계획'이 그거야. 갈림길을 실행 때 고르도록 남겨 둔 좋은 OR-Expansion이지." 선배가 말했다. "오늘 rev02에서 본 건 반대야. 값을 몰라서 양쪽 길을 다 걸은 거지."


오후 3시 40분 — rev03 ​

"그럼 개발팀한테 rev02를 바인드로 고치라고만 하면 위험하네요. 피킹이 꺼지는 날 터지니까."

"인프라팀이 피킹을 안 끄면 괜찮잖아?"

"오늘은 안 끄겠죠. 그런데 다른 데서도 생길 수 있어요." 나는 오전에 DBMS_SQL로 파싱 네 가지를 잴 때 겪은 일을 떠올렸다. 처음에 rev02로 그 실험을 짰다가 바인드 쪽 클릭 한 번이 100만 블록을 넘는 바람에 실험이 끝나지 않았다. 그때 계획의 Outline에도 똑같이 OPT_PARAM('_optim_peek_user_binds' 'false')가 찍혀 있었다. "DBMS_SQL은 값을 묶기 전에 파싱하잖아요. 엿볼 값이 아직 없어요. 피킹을 끈 것과 같아요."

"그래서?"

"그래서 문장을 고쳐야죠. 값을 엿보든 못 엿보든 같은 계획이 나오는 모양으로." 나는 rev02의 다음 페이지 조건을 다시 봤다. 포스트잇 날짜보다 이르거나, 같은 날이면 포스트잇 번호 이하인 행이다. OR를 펼칠 빌미를 주는 건 "이르거나"에 해당하는 OR였다. 같은 뜻을 OR 없이 쓰면 이렇게 된다.

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)

포스트잇 날짜 이하인데, 그중 "같은 날이면서 번호가 더 큰 것"은 뺀다. ORD_SEQ는 NOT NULL이라 두 문장의 뜻이 같다.

피킹을 끈 채로 쟀다.

rev03      2페이지  Buffers 14
rev03    100페이지  Buffers 14
rev03   1000페이지  Buffers 17
rev03  10000페이지  Buffers 16
rev03 100000페이지  Buffers 16

   4 - access("ORD_DT"<=:P_DT)
       filter(("ORD_DT"<>:P_DT OR "ORD_SEQ"<=:P_SEQ))

1,005,991 → 14. 계획은 INDEX RANGE SCAN DESCENDING 하나뿐이었다. 오라클은 내 NOT을 다시 OR로 풀어 적었지만, 이번에는 그것을 펼칠 갈래가 아니라 인덱스 안에서 거르는 조건으로 썼다.

"예쁘진 않네요. NOT (A AND B)는."

"주석으로 뜻을 적어. 사람은 주석을 읽고, 오라클은 문장을 읽어." 선배가 말했다. "그리고 이게 리비전이 되려면?"

T0 제1조는 Buffers N → M이다. 피킹이 켜진 평소에는 rev02도 14, rev03도 14다. 평소 조건에서 한 번 돌려 본 것만으로는 리비전이 되지 않는다. 08화에서 Q2의 '먼저 묶고 붙이기'를 보류한 것도 그래서였다.

"조건을 붙여서 적을게요. 값을 엿보지 못하고 파싱될 때 2페이지 Buffers 1,005,991 → 14. 그런 조건을 오늘 실제로 두 번이나 만났어요. 인프라팀 제안에서 한 번, DBMS_SQL에서 한 번요."

"그럼 남은 건?"

"답이 맞는지요." 08화부터 리포트마다 붙이는 「정합성 회귀 체크리스트」다. 이번에는 포스트잇을 진짜 DATE 바인드로 넘기면서 09화와 같은 검증을 다시 했다.

   100페이지  건수 10 = 10  합계 2,627,200 = 2,627,200  MINUS 0 / 0  순서 같음
    92페이지  건수 10 = 10  합계 2,848,500 = 2,848,500  MINUS 0 / 0  날짜 2종류
100000페이지  건수 10 = 10  합계 3,095,700 = 3,095,700  MINUS 0 / 0
pages=100000 rows=1000000 sum=255398623700 mismatch=0

10만 페이지를 끝까지 넘기며 전체 정렬 결과와 한 줄씩 맞췄다. 빠진 행도, 겹친 행도, 순서가 바뀐 행도 0건이었다. 합계는 한빛유통 데이터의 지문 그대로였다. 그리고 그 10만 번 동안 다음 페이지 문장은 한 번 하드 파싱되고 99,999번 실행됐다.

Q4_rev03이 그렇게 나왔다.


⛳ 오늘은 여기까지(중단 지점 2)Q4_rev03이 나왔다. 값을 엿보지 못하고 파싱될 때 2페이지 1,005,991 → 14, 3만 번 클릭의 하드 파싱 30,431 → 1, 그리고 10만 페이지 정합성 검사 0건이다. 오늘 몫은 여기까지만 해도 다 했다. 이어지는 저녁에는 선배가 "공유 풀에 한 줄"이라는 내 말을 의심하고, 나는 개발팀에 보낼 문서를 쓴다.


오후 4시 30분 — 한 줄이 아니다 ​

박팀장에게 의견을 보내고 나서 선배가 물었다. "바인드로 바꾸면 공유 풀에 문장이 한 줄이라고 했지?"

"네. 아까 [B]에서도 한 줄이었어요."

"Q1은 03화부터 바인드였어. 한 줄이야?"

Q1 rev03을 네 가지 상황에서 불러 봤다. 먼저 보통 세션에서 두 번. 다음은 바인드 변수를 VARCHAR2(200)으로 넉넉하게 선언한 세션에서 한 번으로, 화면 프로그램이 문자열 그릇을 크게 잡는 경우다. 그다음은 optimizer_mode를 FIRST_ROWS_10으로 바꾼 세션에서 한 번으로, 리포트 도구가 접속하면서 그렇게 바꾸는 경우다. 마지막으로 다시 보통 세션에서 불렀다.

VERSION_COUNT  EXECUTIONS  LOADS
            3           5      3

child 1 : BIND_LENGTH_UPGRADEABLE = Y
child 2 : OPTIMIZER_MODE_MISMATCH = Y

문장은 한 글자도 다르지 않았고 부모도 하나였다. 그런데 자식이 셋이었다. 바인드 그릇이 32바이트에서 2000바이트로 커지자 자식 1이 새로 생겼고, 최적화 목표가 달라지자 자식 2가 생겼다.

"부모는 텍스트로 찾고, 자식은 같이 쓸 수 없는 사정이 생길 때마다 생겨." 선배가 말했다. "서식함으로 치면, 같은 '지출결의서' 칸에 결재선이 다른 양식이 여러 장 꽂힌 거야. 칸은 하나, 양식은 여럿. V$SQLAREA가 칸이고 V$SQL이 양식이야. 그리고 V$SQL_SHARED_CURSOR가 '왜 따로 꽂혔는지' 적은 쪽지고."

"FORCE 아래의 Q4가 칸 하나에 양식 329장이었네요. HASH_MATCH_FAILED가 그 쪽지고."

"그래. 바인드로 바꿨는데도 VERSION_COUNT가 크면, 그 쪽지에서 Y인 칸을 읽어."

그러는 사이 개발팀에서 메신저로 질문이 왔다. "정산 목록 고치는 김에 물어봅니다. 화면에서 고객을 여러 명 골라 이름을 붙이는 기능이 있는데, CUS_ID IN (…) 안에 고른 수만큼 값이 들어갑니다. 이것도 바인드로 하면 되나요?"

고르는 수가 1명에서 10명까지 매번 다른 요청 300개를 만들어 네 가지 방식으로 보내 봤다.

[1] 리터럴              하드 파싱 300   문장 300
[2] 길이만큼 바인드      하드 파싱  40   문장  10
[3] 10칸 고정 바인드     하드 파싱   4   문장   1
[4] 컬렉션 하나          하드 파싱   1   문장   1

바인드로만 바꾸면 칸 수가 문장의 일부로 남는다. 길이가 열 가지면 문장도 열 개다. 칸 수를 10으로 고정해 남는 칸을 마지막 값으로 채우거나, 컬렉션 하나로 넘기면 문장은 하나가 됐다. 요청 한 번의 logical reads는 넷 다 12~13블록으로 거의 같았다.


오후 5시 30분 — 한 장의 가이드 ​

선배가 빈 문서를 하나 열어 줬다. "개발팀한테 '바인드 쓰세요' 한 줄 보내면 다음 주에 또 똑같은 일이 생겨. 오늘 잰 걸로 규칙을 써. 이유를 붙여서."

「SQL 코딩 가이드」 v0.1은 18화에서 사내 표준으로 다듬을 문서의 첫 판이다. 조문마다 근거 리포트와 숫자를 붙였다.

 1. 화면이 고르는 값(포스트잇 · 검색어 · 조회월 · 고객번호)은 바인드로 넘긴다.
    문자열로 이어 붙이지 않는다.                       [T1 9호] 3만 클릭 하드 파싱 30,431 → 1
 2. 값이 손에 꼽히고 값마다 길이 달라야 하는 코드값은 리터럴로 쓴다.
    단 상수로 — 화면 입력을 이어 붙이지 않고, 코드에서 문장을 고른다.
                                                     [T1 9호] 상태 탭 EXACT 5,826 · FORCE 50,347
 3. 바인드 문장은 값을 엿보지 못해도 같은 계획이 나오는 모양으로 쓴다.
    갈래가 생기는 OR 는 다시 쓰거나(rev03), 갈림길이 실행 때 골라지게 둔다(08화 NVL).
                                                     [T1 9호] rev02 1,005,991 → rev03 14
 4. IN 리스트는 칸 수를 고정하거나 컬렉션으로 넘긴다.   [11화] 문장 300 → 10 → 1
 5. 화면 프로그램은 커서를 다시 쓴다 (문장 캐시). 단, 값에 따라 길이 갈리는 문장을
    커서를 열어 둔 채 다시 쓰면 ACS 도 자식을 바꾸지 못한다 — 2번을 따른다.
 6. CURSOR_SHARING 은 EXACT. 바인드 피킹은 끄지 않는다.

5번 조문은 마지막에 넣었다. 오후에 ACS를 재면서 하나 더 해 본 실험이 있었다. 같은 100건 화면을 PL/SQL에서 EXECUTE IMMEDIATE ... USING으로 부르면, PL/SQL은 같은 문장을 연달아 부를 때 커서를 열어 둔 채 다시 실행한다. 파스 호출은 1번뿐이다. 그러자 완료 다음에 연 취소 다섯 번이 전부 8,333이었다. SQL*Plus에서는 두 번째에 고쳐졌던 바로 그 문장이었다. 서식함으로 치면, 책상 위에 펼쳐 둔 양식은 총무팀이 결재선을 나눠도 그 사실을 모르는 셈이다.

"ACS가 자식을 고르는 건 파스 호출 때야. 파스 호출이 없으면 고를 기회도 없어." 선배가 말했다. "PL/SQL 배치 이야기는 15화에서 다시 해."

그다음은 리포트였다. T1 9호에는 08화부터 붙여 온 정합성 체크리스트에 더해 「채택하지 않은 대안」 칸이 셋 들어갔다. FORCE, FORCE + 피킹 끄기, rev02를 바인드로만 고치기다.

박팀장에게 보낼 답장은 세 번 고쳐 썼다.

나  17:48
CURSOR_SHARING=FORCE 와 바인드 피킹 끄기 둘 다 반대합니다.

FORCE 를 켜면 정산 목록의 하드 파싱은 크게 줄지만(3만 번 클릭에 30,431 → 329),
정산 상태 확인 화면처럼 일부러 상태값을 박아 둔 화면이 한 계획으로 묶여서,
그날 처음 열린 탭에 따라 취소 탭이 8.6배, 또는 완료 탭이 5.7배 느려집니다.
피킹을 끄면 개발팀이 이번 주에 고칠 정산 목록 SQL 이 2페이지부터 100만 블록을 읽습니다.

대신 정산 목록 SQL 을 한 줄 고쳤습니다(rev03). 바인드로 넘기면 3만 번 클릭에
하드 파싱 1번, 피킹 설정과 상관없이 어느 페이지든 14~17 블록입니다.
개발팀용 'SQL 코딩 가이드' 를 함께 보냅니다. 상세는 T1 9호.

선배가 어깨 너머로 읽었다. "인프라팀 제안을 '틀렸다'고 안 쓴 건 잘했어. FORCE는 실제로 효과가 있었으니까." 그리고 한 줄을 짚었다. "30,431 → 329만 보면 인프라팀은 '거봐, 효과 있잖아'라고 할 거야. 그 옆에 바인드면 1을 붙여."

나는 괄호 안을 고쳤다. (3만 번 클릭에 30,431 → FORCE 329 · 바인드 1).


오후 6시 20분 ​

퇴근 무렵 선배가 하나를 더 물었다. "인프라팀 첫 메시지에 '공유 풀 경보'라고 했지. 공유 풀이 꽉 차면 무슨 일이 생겨?"

"…ORA-04031이요. 공유 메모리를 못 받아서 문장을 아예 실행하지 못하는 에러요."

"오늘 그게 났어?"

나지 않았다. 3만 개를 밀어 넣어도 연습 DB의 공유 풀은 에러 대신 오래된 커서를 내보내며 버텼다. 그 대가가 아침의 Q1이었다. 밀려나는 바람에 다시 하드 파싱을 해야 하는 이웃이다.

"Free 한 대에서는 04031을 재현하지 못했어요. 3만 개를 넣어도 에러 대신 밀어내기로 버텼어요. 공유 풀이 내보낼 수 없는 것, 그러니까 실행 중이거나 열려 있어서 붙잡힌 커서로 가득 차야 할 텐데, 그런 상황을 이 컨테이너 한 대로 만들어 보지는 않았어요."

"그럼 그렇게 적어. '재현하지 못했다. 대신 밀려나는 것을 쟀다.'" 선배가 가방을 챙겼다. "큰 장비에서 그게 어떻게 터지는지는 별권 얘기야."

그러고는 문간에서 돌아섰다.

"rev03은 유효기간이 없어?"

"있어요. 09화 재협상서 1호의 조건 그대로요. 화면에 검색 조건이 붙는 날, 이를테면 상태 탭을 정산 목록에 합치자는 말이 나오는 날에는 이 문장과 인덱스를 다시 재야 해요."

"하나 더." 선배가 말했다. "하드 파싱이 3만에서 1이 되면, 같은 시간에 더 많은 클릭이 처리돼. 그동안 하드 파싱 뒤에 줄 서 있던 게 앞으로 나와. 이번엔 공유 풀이 아니라 데이터 쪽에서."

"…다음 주요?"

"아무도 안 건드렸는데 전체가 멈추는 날이 와." 선배가 웃었다. "12화."


오늘 남은 것 ​

무엇값
Q4_rev03포스트잇은 바인드 · 다음 페이지 조건은 OR 없이. 값을 엿보지 못하고 파싱될 때 2페이지 1,005,991 → 14 · 3만 번 클릭 하드 파싱 30,431 → 1 · 정합성 10만 페이지 mismatch 0
T1 리포트 9호「채택하지 않은 대안」 셋: FORCE · FORCE + 피킹 끄기 · rev02 바인드만
「SQL 코딩 가이드」 v0.1 ★이 교재의 새 표준 문서. 조문 여섯 개, 조문마다 근거 숫자
상태 탭 SQL (강도현)고치지 않는다. 리터럴 셋이 맞다. FORCE 아래에서는 취소 탭 5,826 → 50,347
ORA-04031Free에서 재현하지 못했다. 3만 개를 넣어도 밀어내기로 버텼다. 대신 밀려난 이웃(Q1)을 쟀다

오늘 배운 한 문장 ​

같은 SQL인지는 텍스트가 정하고, 같은 계획을 써도 되는지는 값이 정한다. 값이 바뀌어도 같은 길로 가야 하는 곳은 바인드로, 값마다 길이 달라야 하는 곳은 리터럴로 쓴다. 그 구분은 SQL을 쓰는 사람이 한다. 서버 스위치(FORCE, 피킹 끄기)는 그 구분을 대신하지 못한다.

다음으로 넘기는 것 ​

  • 바인드로 처리량이 오르면 숨어 있던 직렬화가 드러난다 → 12화
  • PL/SQL이 커서를 열어 둔 채 다시 쓸 때: 파스 호출 0의 이득과 ACS를 잃는 대가 → 15화
  • 「SQL 코딩 가이드」 v0.1 → v1.0 (2회 이상 되풀이된 원인만 조문으로) → 18화
  • 화면에 검색 조건이 붙는 날(재협상서 1호의 조건) → 조건이 걸리면 다시 잰다

다음 → 3_실습과_기록.md에서 3만 번을 직접 넘기고, FORCE와 피킹 끄기를 내 세션에서 재고, rev03의 정합성을 확인하고, T1 9호와 SQL 코딩 가이드를 쓴다. 개념이 막히면 → 2_파고들기.md