테마
정합성 회귀 체크리스트 (템플릿)
08화에서 제정했다. T1의 「8. 정합성 회귀 검증」 칸에 이 표를 그대로 붙인다.08화부터는 모든 리포트(T1·T2·T3·T4)에 반드시 붙인다. 이 표가 없는 리포트는 성능이 아무리 좋아도 반려한다.
느린 코드를 빠르게 만드는 것이 튜닝이고, 맞는 코드를 틀리게 만드는 것은 장애다. (02화)
왜 이 문서가 있는가
이런 일이 세 번 있었다. 세 번 모두 Buffers로는 보이지 않았다.
| 화 | 무엇 | 숨은 이유 | 결과 |
|---|---|---|---|
| 02 | 날짜 범위를 BETWEEN … AND DATE '2024-06-30'으로 교정 | T_ORD_BIG의 ORD_DT가 전부 자정이라 결과가 같았다 | 옆 테이블 T_ORD에서 137건 / 34,159,700원 누락 |
| 03 | 영업시간 조건 SLE_HMS BETWEEN '090000' AND '180000' | 0을 채우지 않은 시각이 문자 비교에서 뒤로 밀렸다 | 36,290건 / 297,291,000원 누락 |
| 08 | Q3 상품평가 조인 (M:1:M) | 평가 수가 모든 상품에서 50개라 순위가 원장과 같았다 | 금액 50배. TOP 5 합계가 회사 전체 매출의 3.6배 |
공통점은 하나다. "이 데이터에서 같다"를 "맞다"로 읽었다.
그리고 08화에서 하나가 더 드러났다. 옵티마이저의 쿼리 변환은 답을 지킨다. 틀린 답도 지킨다. Q3 rev00은 옵티마이저가 이미 '먼저 집계하고 곱하는' 모양으로 고쳐 쓰고 있었다(PLACE_GROUP_BY). 그래서 839블록으로 빨랐지만, 금액은 여전히 50배였다. "옵티마이저가 알아서 한다"는 정합성의 근거가 되지 않는다.
체크리스트 — 여덟 항목
| # | 항목 | 묻는 것 | 어떻게 | 08화에서 걸린 것 |
|---|---|---|---|---|
| A | 기준을 정한다 | 무엇과 대조하는가 | 직전 리비전과 원장(업무 정의를 가장 단순하게 쓴 SQL) 둘 다. 직전 리비전이 틀렸을 수도 있다 | Q3 rev00은 처음부터 틀렸다. 원장 = 평가를 붙이지 않은 상품별 합계 |
| B | 세 가지로 대조한다 | 건수 · 합계 · 양방향 MINUS | 셋 다 한다. 하나라도 빠지면 통과로 치지 않는다 | MINUS만 보면 0 / 0인데 건수는 5,000 대 100인 안이 있었다. MINUS는 중복을 보지 못한다 |
| C | 잘라 내기 전 전체로 | TOP N · 페이징 · FETCH FIRST를 뗀 결과로 대조했는가 | 화면에 나오는 5줄이 아니라 그 앞의 전체 집합으로 대조 | TOP 5는 순위가 같아 보였다. 100개 상품 전체로 대조하니 MINUS 100 / 100 |
| D | 조인마다 '1 쪽'을 확인한다 | 조인 상대가 그 키로 정말 한 줄인가 | PK·UK 확인 + A-Rows가 조인 전후로 불어나는가 | T_ITM_EVL은 ITM_ID로 50줄. 주문상세 200,000 → 조인 10,000,000 |
| E | 경계 데이터를 만든다 | 해당되는 경계를 복사본에 만들어 대조했는가 | 아래 「경계 목록」. 원본에는 쓰지 않는다 | 평가 없는 상품 · 코드에 없는 키(고아) · 코드 중복 · NULL · 고르지 않은 분포 |
| F | 계획이 같아도 대조한다 | Plan hash value가 같으니 답도 같다고 넘기지 않았는가 | 계획 해시에는 연산의 모양만 담기고 식은 담기지 않는다 | 틀린 SUM(DISTINCT) 안과 맞는 EXISTS · IN의 계획 해시가 같았다(2231864090) |
| G | 변환에 기대지 않는다 | "옵티마이저가 고쳐 쓰니까 괜찮다"를 근거로 쓰지 않았는가 | 변환은 뜻을 보존할 뿐 고치지 않는다 | Q3 rev00은 PLACE_GROUP_BY로 빨랐지만 여전히 50배 |
| H | 영향 분석 | 카디널리티를 바꾸는 변경(PK·UK 변경, 코드 테이블 재적재, 다건 허용)이 있었는가 | 그 테이블을 조인하는 SQL 전부를 회귀 대상에 올린다 | 2020-02 T_ITM_EVL PK 변경 커밋에 Q3가 빠져 있었다 |
경계 목록 — E 항목에서 고른다
| 경계 | 무엇을 만드나 (복사본에서) | 무엇이 갈리나 | 처음 본 화 |
|---|---|---|---|
| 시분초 | 자정이 아닌 시각 | 날짜 BETWEEN의 끝값 | 02 |
| 0 패딩 | 자릿수가 모자란 문자 숫자 | 문자 범위 비교 | 03 |
| NULL | 서브쿼리 쪽 컬럼에 NULL 한 줄 | NOT IN은 0행, NOT EXISTS는 정상(EMP 실측 0명 대 8명) | 08 |
| 1 쪽에 없는 키 (고아) | 코드 테이블에 없는 코드 | 내부조인은 그 행을 지우고, 외부조인·스칼라는 남긴다(Q2 실측: 내부조인만 주문 10,000건 감소) | 08 |
| 1 쪽 중복 | 코드 테이블을 두 번 적재 | 조인은 조용히 두 배가 되고, 스칼라는 ORA-01427로 멈춘다(Q2 실측: A 등급 2,564,322,100 → 5,128,644,200) | 08 |
| 고르지 않은 분포 | 곱해지는 수가 행마다 다른 부분집합 | 모두 같은 배수일 때는 숨어 있던 순위 뒤집힘(Q3 실측: 12월 평가 기준으로 원장 6위 ITM00041이 1위로) | 08 |
| 요구 조건이 실제로 거르는 경우 | 거름 조건에 걸리는 행(예: 평가 없는 상품) | 조건을 지운 안과 지키는 안(Q3 실측: MINUS 1 / 0) | 08 |
| 시각 · 세션 (18화 추가) | 같은 대조를 경계 시각(18시 정각 · 자정 · 금 → 월)과 다른 세션(시간대 · NLS · 직원)에서 한 번 더 한다. 시각을 인자로 넣을 수 있게 고친 판으로 돌린다 | 시계 · 세션 설정에 기대는 함수 · 결과 캐시 · DETERMINISTIC. 같은 날 안에서 하는 대조는 전부 같게 나온다(15b화: 경계 행이 있으면 목록 100,001 vs 100,000 · MINUS 1 / 0 · 03화: NLS DD-MON-RR 27,390건 ↔ YYYY-MM-DD 1,000,000건) | 03 · 15b |
대조 SQL 틀 — 복붙해서 쓴다
sql
-- a = 새 리비전, b = 원장 또는 직전 리비전. ORDER BY · FETCH FIRST 는 떼고 넣는다 (항목 C)
WITH a AS ( /* 새 리비전 */ ),
b AS ( /* 원장 */ )
SELECT (SELECT COUNT(*) FROM a) AS a_rows,
(SELECT COUNT(*) FROM b) AS b_rows,
(SELECT SUM(/* 금액 컬럼 */) FROM a) AS a_sum,
(SELECT SUM(/* 금액 컬럼 */) FROM b) AS b_sum,
(SELECT COUNT(*) FROM (SELECT * FROM a MINUS SELECT * FROM b)) AS a_minus_b,
(SELECT COUNT(*) FROM (SELECT * FROM b MINUS SELECT * FROM a)) AS b_minus_a
FROM dual;
-- 키가 중복되지 않는가 (MINUS 의 맹점을 막는다 - 항목 B)
SELECT /* 키 컬럼 */, COUNT(*) FROM ( /* 새 리비전 */ ) GROUP BY /* 키 컬럼 */ HAVING COUNT(*) > 1;통과 조건:
a_rows = b_rows·a_sum = b_sum·a_minus_b = 0·b_minus_a = 0· 중복 키 0행. 다섯 개를 모두 만족해야 한다.
양식 — 리포트에 붙이는 표
| # | 항목 | 대상 / 방법 | 결과 | 판정 |
|---|---|---|---|---|
| A | 기준 | |||
| B | 건수 · 합계 · MINUS 양방향 · 중복 키 | |||
| C | 잘라 내기 전 전체 | |||
| D | 조인마다 1 쪽 | |||
| E | 경계 데이터 (고른 것) | |||
| F | 계획 해시와 무관하게 대조했나 | |||
| G | 변환을 근거로 쓰지 않았나 | |||
| H | 영향 분석 |
| 열 | 적는 것 | 비었을 때 |
|---|---|---|
| 대상 / 방법 | 대조한 SQL 이름 · 쓴 복사본 이름 | 반려 |
| 결과 | 숫자 그대로 (건수 · 합계 · MINUS 행 수) | 반려 |
| 판정 | 통과 / 불일치 / 해당 없음 | 「해당 없음」은 이유를 같은 칸에 쓴다. 빈칸과 다르다 |
작성 예 (08화 · T1 6호 · Q3 rev00 → rev01)
| # | 항목 | 대상 / 방법 | 결과 | 판정 |
|---|---|---|---|---|
| A | 기준 | 원장 = T_ORD_DET 상품별 합계(평가 조인 없음). 직전 리비전 rev00은 틀린 것으로 밝혀짐 | — | 정함 |
| B | 건수 · 합계 · MINUS · 중복 키 | rev01 vs 원장, 100개 상품 전체 | 100 = 100 · 30,272,557,700 = 30,272,557,700 · 0 / 0 · 중복 0 | 통과 |
| C | 잘라 내기 전 전체 | FETCH FIRST 5를 뗀 100행으로 | (B와 같음) | 통과 |
| D | 조인마다 1 쪽 | T_ITM_EVL은 ITM_ID로 50줄 → 조인을 세미 조인(EXISTS)으로 바꿈 | 해시 조인 입력 200,000 → 출력 200,000 (불어나지 않음) | 통과 |
| E | 경계 데이터 | 복사본 T_ITM_EVL_C08에서 ITM00071의 평가를 뺌(평가 없는 상품) | rev01은 ITM00071 제외(요구대로). 조인 삭제안은 포함 → MINUS 1 / 0 | 통과(조인 삭제안 기각 근거) |
| F | 계획 해시 | 틀린 SUM(DISTINCT) 안과 rev01의 계획 해시가 같음(2231864090) | 계획 해시를 근거로 쓰지 않음 | 통과 |
| G | 변환 | rev00의 PLACE_GROUP_BY · rev01의 UNNEST는 성능 설명에만 씀 | — | 통과 |
| H | 영향 분석 | T_ITM_EVL을 조인하는 SQL 목록 | 6조각 중 Q3 하나 | 통과 |
소급 — 08화 이전 리포트에도 붙인다
08화 이전 리포트도 이 표를 붙여 다시 낸다. 우선 B 항목만 채운다(08화 실측).
| 리포트 | 조각 | 대조 | 결과 | 판정 |
|---|---|---|---|---|
| T1 1호 (02화) | Q1 rev00 vs rev02 | 건수 · 합계 · MINUS | 27,390 = 27,390 · 6,982,065,200 = 6,982,065,200 · 0 / 0 | 통과(경계: 시분초는 T_ORD에서 따로 확인함) |
| T1 4호 (06화) | Q2 rev00 vs rev01 | 건수 · 합계 · MINUS | 3 = 3 · 255,398,623,700 = 255,398,623,700 · 0 / 0 | 통과(경계: 지역 코드 중복 · 고아는 08화 실습/03 [C]) |
운용
| 언제 | 무엇 |
|---|---|
| 리비전을 올릴 때마다 | 이 표를 T1 8번 칸에 붙인다. 표가 없으면 리비전을 올리지 않는다 |
| 인덱스·힌트만 바꿀 때도 | SQL이 한 글자도 바뀌지 않았어도 붙인다(계획이 바뀌면 경계에서의 동작이 달라질 수 있다) |
| 카디널리티를 바꾸는 DDL이 있을 때 | H 항목에 따라 해당 테이블을 조인하는 SQL을 모두 찾아 B 항목을 다시 돌린다 |
| 18화 졸업 판정 | 6조각 모두 원장 대조를 다시 돌린다. 불일치가 하나라도 남아 있으면 미완성이다 |
| (18화 결과) | 6조각 원장 대조 결과 불일치 0(W01 원장 18화 절) |
개정 이력: 08화에 제정했다(A~H · 경계 목록 일곱 줄). 18화에 경계 목록의 '시각 · 세션' 줄(15b화 T1 11호 · 03화 T1 2호, 사내 튜닝 표준 제15조)과 졸업 판정 결과를 더했다.