Skip to content

15b화 · 같은 답을 두 번 계산하지 마라 ​

6월 17일 월요일. 지난 금요일 오후, 선배가 정산 등록 화면의 함수 두 개에 한 단어씩을 붙였다. RESULT_CACHE였다. 오늘은 선배가 틀린다. 그리고 틀린 사람이 선배일 때 무엇을 재야 하는지 배운다.


오전 9시 05분 ​

월요일 아침이면 늘 하는 대로 리비전 폴더부터 열었다. 맨 위에 지난주의 ADJ_rev01이 있었고, 15화 T3 2호의 12번 칸 ①에 기한이 걸려 있었다. rev01 운영 적용 · 운영 소요를 다시 잰다 (오과장, 6/17). 바로 오늘 10시였다. 결과는 오과장이 로그로 남기기로 했다.

그 아래로는 이번 주에 고칠 조각이 없었다. 6조각은 모두 제자리였다. 이번 주는 조용하겠다고 생각했다.

메신저가 먼저 울렸다.

조민우  09:11
오늘 아침에 등록한 정산이 전부 정산일 6/14(금)로 찍혀 있어요.
금요일 저녁 6시 넘어서 등록한 것도 6/14 고요. 6시 넘으면 다음 날로 넘어가야 하잖아요.
지난주까지는 안 그랬어요. 화면을 다시 열어도 그대로예요.

정산일은 정산 등록 화면이 저장할 때 붙이는 날짜다. 18시 전에 등록하면 그날, 18시가 넘으면 다음 날이 된다. 정산팀의 마감 규칙이었다.

화면 소스부터 찾아보려는데 선배가 커피를 들고 들어왔다. 내 어깨 너머로 메시지를 읽더니 컵을 책상에 내려놓았다. 한동안 말이 없었다.

"금요일 다섯 시 반." 선배가 말했다. "그 화면이 부르는 함수 두 개에, 내가 한 단어씩 붙였어."


오전 9시 15분 — 금요일 다섯 시 반 ​

지난주 목요일에 조민우 씨가 선배에게 부탁을 하나 했다고 했다. 정산 등록 화면에서 정산 목록을 열면 '예상 보정액' 칸이 뜨는 데 몇 초씩 걸린다는 것이었다. 15화의 그 보정률 함수 FN_ADJ_RATE를 행마다 부르는 칸이었다.

"너 지난주 월요일에 등급표를 패키지 변수에 두려다 데었지. 그때 내가 화면에 줄 거면 결과 캐시라는 게 따로 있다고 했고." 선배가 의자를 끌어왔다. "금요일에 그걸 붙였어. 오늘 너한테 보여 주려고. 보정률 함수에 RESULT_CACHE를. 그리고 화면이 같이 부르는 정산일 함수 FN_STLM_DT에도."

"연습 DB에서 재 보셨어요?"

"쟀지. 화면 목록하고 예상 보정액을, 붙이기 전과 붙인 뒤로 대조했어. 건수, 합계, 양방향 MINUS. 08화 체크리스트 그대로. 전부 같았어. 개발팀이 다섯 시 반 정기 반영에 넣었고, 롤백 절차도 써서 같이 넘겼어." 선배가 휴대폰을 꺼냈다. "그 절차부터 돌릴게. 원래 함수로 되돌리는 거야."

선배는 박팀장에게 짧게 보고하고 개발팀에 전화를 걸었다. 통화는 2분 만에 끝났다. 그러고는 나를 봤다.

"되돌리는 건 내가 해. 너는 왜 그랬는지 찾아." 선배가 말했다. "이번엔 내가 한 대조를 증거로 쓰지 마. 그게 통과했는데 이렇게 됐으니까."

9시 40분, 개발팀이 두 함수를 금요일 이전 소스로 다시 만들었다. 3분 뒤 조민우 씨에게서 메시지가 왔다.

조민우  09:43
방금 등록한 건 6/17 로 나와요. 금요일 저녁부터 오늘 아침까지 등록한 건 저희가 정산일을 고칠게요.
등록 화면 로그로 목록을 뽑아 주실 수 있어요?

로그는 개발팀이 뽑기로 했다. 일단 피는 멈췄다. 이제 왜 피가 났는지 찾을 차례였다.


오전 9시 45분 — 무엇에 기대어 적었나 ​

답은 이미 안다고 생각했다. 결과 캐시는 책에서 읽은 적이 있었다.

"결과 캐시는 기대는 테이블이 바뀌면 지워진다고 했잖아요. 그런데 금요일 저녁부터 오늘 아침까지 정산이 계속 등록됐어요. 정산 테이블이 계속 바뀌었는데 캐시가 안 지워진 거예요." 내가 말했다. "무효화가 고장 난 거 아니에요? 화면 세션들은 다 따로 커밋하니까, 남의 세션이 커밋한 걸 캐시가 못 본다든가."

"그럼 재 봐. 고장인지."

연습 DB에 정산 복사본과 고객 마스터 복사본을 떴다. 그리고 금요일 판 함수 두 개(정산일 함수와 보정률 함수)를 복사본 쪽에 그대로 심었다. 한 번씩 부른 뒤, 결과 캐시가 각 답을 무엇에 기대어 저장했는지 확인했다. V$RESULT_CACHE_DEPENDENCY 뷰를 보면 알 수 있다.

결과                        기대는 것 (의존 객체)
보정률 함수의 답            SQLP.FN_ADJ_RATE_RC_C15B   (함수 자신)
                            SQLP.M_CUS_C15B            (고객 마스터 — 함수 안의 SELECT 가 읽는 테이블)
정산일 함수의 답            SQLP.FN_STLM_DT_RC_C15B    (함수 자신)       ← 이게 전부

정산일 함수의 답은 함수 자신 하나에만 기대고 있었다. 정산 테이블은 목록에 없었다.

정산을 한 줄 등록하고 커밋했다. 두 답 모두 Published 그대로였다. 이번엔 고객 마스터를 고쳤다. 등급을 같은 값으로 덮어쓰는 UPDATE 하나를 커밋했다.

보정률 함수의 답   Invalid      ← 고객 마스터가 바뀌어 커밋되자 지워졌다
정산일 함수의 답   Published

"…무효화는 멀쩡해요." 화면을 보며 말했다. "보정률은 고객 마스터가 바뀌니까 바로 지워졌어요. 정산일은 정산 테이블에 기대지 않으니까 정산을 넣어도 안 지워진 거고요. 고장이 아니라… 지울 이유가 없었던 거예요."

"그럼 정산일 함수는 뭐에 기대?"

함수를 열었다. 본문은 한 줄이었다.

sql
FUNCTION FN_STLM_DT RETURN DATE DETERMINISTIC RESULT_CACHE IS
BEGIN
  RETURN TRUNC(SYSDATE + 6/24);       -- 18:00 에 6시간을 더하면 자정을 넘는다 → 18시 이후 등록은 다음 날
END;

SYSDATE. 시계를 읽고 있었다.

"시계는 테이블이 아니야." 선배가 말했다. "결과 캐시가 답을 지우는 근거는 '기대는 객체가 바뀌어 커밋됐다'는 사실 하나야. 이 함수의 진짜 입력은 시계인데, 시계는 객체 목록에 없어. 그러니까 아무도 안 지워. 금요일 다섯 시 반이 넘어 처음 불렸을 때의 답, 6/14가 오늘 아침까지 그대로 남은 거야."

결과 캐시가 무엇에 기대어 답을 저장하는지, 무엇이 그 답을 지우는지는 2_파고들기.md §4에 있다.


오전 10시 00분 — 7층 복도의 화이트보드 ​

"따라와." 선배가 일어섰다. "정산팀에 정정 목록 얘기도 해야 하니까."

7층에서 엘리베이터 문이 열리자 정산팀 유리문 옆에 화이트보드가 하나 서 있었다. 맨 위에는 매직으로 「자주 묻는 것」이라고 쓰여 있었다. 칸이 열두 개쯤 그어져 있었고, 칸마다 질문 하나에 답 하나가 적혀 있었다.

「자주 묻는 것」
  보정률 (A / B / C 등급)      →  0.1% · 0.2% · 0.3%  (규정 2분기)
  이번 주 정산 확정            →  월 11:00
  오늘 등록하면 정산일은?      →  6/14 (금)
  금액 미확정 등록             →  다음 날 오전까지 확정
  …
  ※ 규정집이 바뀌면 그 칸을 지웁니다 — 정산팀

조민우 씨가 문 앞에서 우리를 보고 웃었다. "그거 금요일에 저희 막내가 쓴 거예요. 6시 마감 때문에 '지금 등록하면 며칠로 들어가요?'라고 다들 물어서요. 아… 오늘 아침에도 다들 저걸 보고 6/14인 줄 알았겠네요."

나는 웃음이 나왔다. 선배는 웃지 않았다.

"이게 결과 캐시야." 선배가 보드를 손가락으로 짚었다. "질문의 답을 한 번 구해서, 모두가 지나가다 보는 데 적어 둔 거야. 다음 사람은 계산을 안 해. 읽기만 하지. 층 전체가 같은 보드를 봐."

"07화의 탕비실 선반이랑은 뭐가 달라요?"

"선반에 놓이는 건 캐리어야. 재료지. 블록 말이야. 그걸 가져다가 매번 네가 다시 계산해. 이 보드에 적히는 건 계산이 끝난 답이야." 선배가 다음 칸을 가리켰다. "그리고 07화 오후에 본 그 메모판, 스칼라 서브쿼리 캐시는 네 책상 위에 한 문장이 도는 동안만 붙어 있다가 떨어지지. 이건 복도에 있고, 누가 지우기 전까지는 안 떨어져."

"누가 지우는데요?"

"저기 쓰여 있잖아." 보드 아래에 적힌 한 줄이었다. 규정집이 바뀌면 그 칸을 지웁니다. "보정률 칸은 규정집에 기대니까, 규정집이 바뀌면 막내가 지워. 그게 오라클의 자동 무효화야. 기대는 객체가 바뀌면 지운다." 선배가 세 번째 칸을 짚었다. "이 칸은 뭐에 기대?"

오늘 등록하면 정산일은? 답은 시각에 따라 달라진다. 오후 5시 59분과 6시 1분이 다르고, 금요일과 월요일이 다르다.

"…달력이요. 시계."

"달력이 넘어가면 지우라는 규칙은 저 보드에 없어. 규정집만 있지." 선배가 말했다. "그래서 금요일 3시의 답이 월요일 아침까지 붙어 있는 거야. 아무도 안 틀렸어. 막내는 그때 맞는 답을 적었고, 지우는 규칙도 지켰어. 애초에 보드에 적으면 안 되는 질문이었을 뿐이야."

조민우 씨가 매직을 들고 와서 세 번째 칸을 지웠다. 그리고 그 자리에 "18시 전 = 오늘 · 18시 후 = 내일"이라고 썼다. 답이 아니라 규칙을 적은 것이다. 나는 그걸 사진으로 찍어 두었다.

내려오는 엘리베이터에서 선배가 한마디 더 했다. "보드에 칸이 열두 개뿐이지. 고객 2,000명의 등급을 저기 다 적을 수 있을까? 이따 재 봐. 오라클의 보드에도 칸 수가 있어."

캐싱 4계층(선반, 보드, 메모판, 책상 위 등급표)은 2_파고들기.md §1에 정리했다.


오전 10시 20분 — 하루를 기다릴 수는 없다 ​

자리로 돌아와 재현부터 하기로 했다. 금요일 판 정산일 함수가 날짜가 바뀌어도 같은 날짜를 돌려주는 모습을 연습 DB에서 직접 봐야 했다. 그사이 보정 배치 rev01이 10시부터 운영에서 처음 돌기 시작했다. 그쪽은 오과장이 지켜보기로 했다.

"날짜를 어떻게 바꾸죠? 컨테이너 시계를 내일로 돌리면…"

"시계는 건드리지 마." 선배가 바로 막았다. "그 DB에서 다른 것도 재잖아. 다음 주에는 AWR 스냅샷 시각도 맞춰야 하고. 시계를 돌리면 측정 환경 전체가 망가져."

SYSDATE를 고정하는 FIXED_DATE라는 파라미터도 있었다. 연습 DB에서 한 번 시험해 봤는데, 되돌린 뒤에도 PDB 설정에 FIXED_DATE = 'NONE' 한 줄이 남았다. RESET으로도 지워지지 않아서, 선배가 그 한 줄을 정리하는 데 십 분이 걸렸다. 그 방법은 접었다.

"하루를 기다릴 수 없으면 단위를 바꿔." 선배가 말했다. "같은 모양의 함수를 TRUNC만 빼고 만들어. 그럼 SYSDATE가 초까지 그대로 나와. 하루 대신 2초만 기다리면 돼."

정산일 함수와 같은 두 단어(DETERMINISTIC RESULT_CACHE)를 붙이고 본문만 RETURN SYSDATE로 바꾼 '초' 판을 만들었다. 비교용으로 본문이 같은 그냥 함수도 옆에 두고, 2초 간격으로 세 번 불렀다.

call 1  now 17:10:22  plain 17:10:22  result_cache 17:10:22  (result_cache body ran 1)
call 2  now 17:10:24  plain 17:10:24  result_cache 17:10:22  (result_cache body ran 0)
call 3  now 17:10:26  plain 17:10:26  result_cache 17:10:22  (result_cache body ran 0)

첫 값에 멈췄다. 함수 본문은 첫 호출에 한 번 돌고 다시는 돌지 않았다. 시계가 가는 동안 캐시는 처음 본 시각을 계속 돌려줬다.

"이게 화면에서는 어떻게 보일까." 선배가 말했다. "정산 목록 SQL에 이 함수가 들어가면."

목록은 '지금까지 들어온 정산'으로 정의했다. 조건은 WHERE STLM_DT <= '초' 판이다. 목록을 한 번 열고(그 순간 캐시가 언다), 2초 뒤 정산 한 줄을 지금 시각으로 등록했다. 그리고 두 판으로 셌다.

                                   건수
캐시 없는 판                     100,001
RESULT_CACHE 판                  100,000
없는 판 MINUS 캐시 판                  1      ← 900002 : 방금 등록한 정산
캐시 판 MINUS 없는 판                  0

방금 등록한 정산이 에러도 경고도 없이 목록에서 사라졌다.

그 한 줄을 지우고 다시 셌다. 100,000 = 100,000. MINUS 0 / 0.

선배가 그 숫자를 오래 봤다. "금요일에 내가 한 대조가 바로 이거였어. 정산 복사본의 정산일은 전부 과거야. 오늘 날짜가 6/14든 6/17이든 목록은 똑같이 나와. 이 데이터에서 같았던 거지, 맞은 게 아니었어." 02화의 T_ORD_BIG이 전부 자정이라 BETWEEN이 통과했던 것과 같은 모양이었다. 08화 체크리스트 E에 따르면 경계 데이터는 복사본에 직접 만들어 넣어야 한다. 금요일의 대조에는 시각의 경계가 없었다.

그래도 '초'는 '날짜'가 아니었다. 날짜가 하루 넘어가는 장면을 봐야 했다.

"SYSDATE는 세션 시간대를 안 따라. 서버 시계니까." 선배가 말했다. "CURRENT_DATE는 따라. 정산일 함수를 CURRENT_DATE로 쓴 판을 만들고, 세션 시간대를 24시간 떨어뜨려. 그럼 같은 순간에 '어제'인 세션과 '오늘'인 세션이 생겨."

먼저 세션 시간대를 -11:00으로 놓고 불렀다. '어제'에 있는 직원이다. 그다음 +13:00으로 바꿔 다시 불렀다. '오늘'에 있는 직원이다. D는 오늘의 직원이 받아야 할 정산일이다.

tz -11:00 (어제)   캐시 없는 판 D-1   RESULT_CACHE 판 D-1
tz +13:00 (오늘)   캐시 없는 판 D     RESULT_CACHE 판 D-1      ← 어제의 답을 오늘도
  SYSDATE 는 두 세션에서 같은 시각 · CURRENT_DATE 만 24시간 움직였다

캐시는 어제의 답을 오늘도 돌려줬다.

마지막으로 스크립트가 끝난 뒤 새 세션을 하나 열었다. 다른 직원이 화면을 연 셈이다. 그 세션은 정산일을 계산한 적이 없었다.

새 세션 (PL/SQL)   now 17:10:36   result_cache 17:10:33   body ran 0      ← 다른 세션이 얼린 값
새 세션 +13:00     캐시 없는 판 D            result_cache D-1
새 세션 NLS DD.MM  캐시 없는 판 14.06        result_cache 06-14         ← 앞 세션의 날짜 형식 (MM-DD)
새 세션 직원 park  캐시 없는 판 owner=park   result_cache owner=kim     ← 앞 세션에 적혀 있던 직원

얼음은 인스턴스 전체에 걸려 있었다. 내가 얼린 답을 남의 세션이 받았다. 날짜 형식도 마찬가지였다. 형식 없이 날짜를 글자로 바꾸는 함수에 결과 캐시를 붙였더니, 처음 부른 세션의 NLS_DATE_FORMAT이 모든 세션의 답이 됐다. 화면이 세션에 적어 두는 로그인 직원(CLIENT_IDENTIFIER)을 돌려주는 함수도 같았다. 앞 세션에서 kim으로 불렀더니 park의 세션에도 kim이 나왔다.

"시계, 세션 시간대, 세션 형식, 세션에 적힌 직원." 선배가 손가락 네 개를 접었다. "넷 다 인자로 안 들어오는 입력이야. 함수 밖에서 몰래 들어오는 입력. 결과 캐시는 인자만 보고 같은 질문인지 판단해. 몰래 들어온 입력이 다르면, 다른 질문에 같은 답을 주는 거지."

비결정적 함수, 숨은 입력 넷, 재현 방법은 2_파고들기.md §5에 있다.


오전 11시 10분 — 커밋 로그 한 줄 ​

"그런데 선배님, 이 함수에는 처음부터 DETERMINISTIC이 붙어 있었어요." 금요일 이전 소스를 열어 보니 선배가 붙인 건 RESULT_CACHE 한 단어였다. 앞의 DETERMINISTIC은 원래 있던 것이었다.

오과장에게 물어 커밋 로그를 받았다.

2019-04-08  hd.kang
  정산일 18시 마감 규칙 (정산팀 요청). 정산 목록이 행마다 불러 느림 —
  인자가 없으니 DETERMINISTIC. 문장마다 한 번만 부른다 (책 p.420)

선배가 그 줄을 읽고 한참 있다가 말했다. "나도 그걸 봤어. 금요일에. 작성자가 '결정적'이라고 적어 놨고, 인자도 없었어. 언제 불러도 같은 답이라는 뜻으로 읽었지."

"강도현 씨가 틀린 거예요?"

"재 봐. 강도현 씨 판이 그때 무슨 일을 했는지."

정산 등록 화면의 목록 SQL('정산일까지 온 정산')을 정산 복사본 10만 행에 대고 네 판으로 돌렸다. 함수에는 정산일 본문이 몇 번 도는지 세는 계수기를 달고, 결과 캐시에서 몇 번 찾았는지도 셌다.

목록 SQL 의 정산일 (정산 10만 행 · FULL)    정산일 본문   결과 캐시에서 찾음    경과    Buffers
그냥 함수 (두 단어 다 없음)                    100,000            0          12 cs     549
강도현 2019 (DETERMINISTIC)                          1            0           0 cs     549
선배 금요일 판 (+ RESULT_CACHE)                      0            1           0 cs     549

표를 두 번 읽었다.

강도현의 DETERMINISTIC이 이미 10만 번을 한 번으로 줄여 놓았다. 선배의 RESULT_CACHE는 그 한 번을 '캐시에서 한 번 찾기'로 바꿨을 뿐이다. 경과 시간은 둘 다 0cs였다. 금요일의 한 단어가 이 목록에서 번 것은 없었다.

"…그건 몰랐어." 선배가 말했다. "재지 않았으니까. 보정률 함수에 붙이면서, 화면이 같이 부르니까 같이 붙였어. 얻을 게 있는지는 안 쟀고, 잃을 게 있는지만 대조했지. 그것도 같은 날 오후 안에서."

선배가 커밋 로그를 다시 짚었다. "강도현 씨 약속은 엄밀하게는 거짓이었어. 시계를 읽는 함수가 '같은 입력이면 같은 답'일 리가 없으니까. 그런데 DETERMINISTIC이 기억하는 범위는 한 문장, 한 번의 페치 안이야. 한 문장 안에서 '지금'은 어차피 하나지. 그래서 그 거짓말로 다친 사람은 없었어. 그때는 옳았던 거야."

"선배님은 그 약속 위에…"

"인스턴스 전체에, 기한 없이 쌓았지." 선배가 말을 받았다. "DETERMINISTIC은 캐시가 아니야. 약속이야. 그리고 그 약속은 아무도 검사하지 않아."

정말로 검사하지 않는지 재 봤다. 같은 /*+ RESULT_CACHE */ 힌트를 단 문장 셋을 2초 간격으로 두 번씩 돌렸다.

F1  SYSDATE 를 문장에 직접 쓴 것          계획에 RESULT CACHE 없음 (거절)   시각이 간다
F2  SYSDATE 를 숨긴 그냥 함수를 부른 것    계획에 RESULT CACHE 있음          시각이 멈춘다
F3  같은 함수에 DETERMINISTIC 을 붙인 것   계획에 RESULT CACHE 있음          시각이 멈춘다

오라클은 문장에 SYSDATE가 보이면 결과 캐시를 거절했다. 그런데 SYSDATE가 함수 안에 숨으면 들여다보지 않고 받아 줬다. 이 DB의 result_cache_integrity는 TRUSTED였다. 선배가 SYS로 연습 DB를 몇 초 동안 ENFORCED로 바꿔 같은 셋을 다시 돌렸다. F2는 거절당했다. F3은 그래도 캐시됐고 시각이 멈췄다. ENFORCED가 믿는 것은 DETERMINISTIC이라고 적힌 글자였다.

"오라클도 그 약속을 믿어." 선배는 바로 TRUSTED로 되돌리고, PDB 설정 파일에 아무것도 남지 않은 걸 확인했다. "거짓 약속을 막아 주는 스위치는 없어. 약속을 하는 사람이 지켜야 해."

⛳ 오늘은 여기까지(중단 지점 1) 여기까지가 오늘의 절반이다. 결과 캐시는 고장 나지 않았다. 정산일 함수의 답이 기대는 것은 함수 자신뿐이었고, 진짜 입력인 시계는 의존 객체가 아니라서 아무도 지우지 않았다. '초' 판으로 재현하니 첫 값에 멈췄고(방금 등록한 정산 1건이 목록에서 빠짐), 시간대 판으로 재현하니 어제의 답을 오늘도 돌려줬다. 그리고 그 한 단어로 목록에서 번 것은 없었다. DETERMINISTIC이 이미 10만 번을 1번으로 줄여 놓았기 때문이다. 이어서 읽으면 결과 캐시가 맞는 자리를 찾고, 오라클의 보드에도 칸이 있다는 걸 재고, 정산일 함수를 제대로 고친다.


오후 1시 00분 — 같은 답이 맞는 자리 ​

"그럼 결과 캐시는 쓰면 안 되는 거예요?"

"보정률 함수는 맞는 자리였어. 금요일의 두 단어 중 하나는 맞았던 거야." 선배가 말했다. "아침에 둘 다 되돌린 건, 같은 날 같은 손으로 바꾼 건 다시 재 보기 전까지 함께 의심해야 해서야. 오후엔 그걸 재. 그리고 캐시가 네 겹이라고 했지. 네 겹을 같은 일에 나란히 놓고 재 봐."

잴 일은 둘이었다. 하나는 08화가 넘긴 질문, 같은 3행을 실행마다 다시 계산하지 않기였다. 고객 등급별 매출 세 줄을 내는 Q2다. 다른 하나는 정산 등록 화면의 예상 보정액으로, 정산 10만 행마다 보정률 함수를 부른다.

Q2를 열 번 돌렸다. 버퍼 캐시만 쓰면 한 번에 5,868블록, 열 번에 58,685블록이다. 디스크는 한 번도 안 갔지만 매번 100만 행을 다 읽었다. 같은 문장에 /*+ RESULT_CACHE */를 붙였다.

xplan
두 번째 실행
--------------------------------------------------------------------------------------------
| Id  | Operation                   | Name                       | Starts | A-Rows |   A-Time   |
--------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT            |                            |      1 |      3 |00:00:00.01 |
|   1 |  TABLE ACCESS BY INDEX ROWID| M_RGN                      |      0 |      0 |00:00:00.01 |
|*  2 |   INDEX UNIQUE SCAN         | PK_M_RGN                   |      0 |      0 |00:00:00.01 |
|   3 |  RESULT CACHE               | au453vfkrprfdd2pumz6c1kmvx |      1 |      3 |00:00:00.01 |
|   4 |   HASH GROUP BY             |                            |      0 |      0 |00:00:00.01 |
|*  5 |    HASH JOIN                |                            |      0 |      0 |00:00:00.01 |
|   6 |     TABLE ACCESS FULL       | M_CUS_C15B                 |      0 |      0 |00:00:00.01 |
|   7 |     TABLE ACCESS FULL       | T_ORD_BIG                  |      0 |      0 |00:00:00.01 |
--------------------------------------------------------------------------------------------

두 번째 실행부터 RESULT CACHE 아래 줄들의 Starts가 0이었다. 아래 연산이 아예 돌지 않은 것이다. Buffers 칸도 사라졌다. 읽은 블록이 0이라 DISPLAY_CURSOR가 칸을 뺀 것이다. 첫 번째 실행은 5,867블록을 읽어 답을 적었고, 두 번째부터 아홉 번은 0블록이었다. 옆 세션을 하나 띄워 같은 문장을 물었다. 논리 읽기 0, 같은 답.

"08화 질문의 답이 나왔네요. 5,868이 0이 돼요."

"이 연습 DB에서는." 선배가 말했다. "Q2가 기대는 테이블이 뭐야?"

"고객 마스터하고… T_ORD_BIG, 주문이요."

"운영의 주문 테이블은 주문이 들어올 때마다 커밋돼. 아침에 봤지? 기대는 테이블이 커밋되면 답이 지워져. 그럼 Q2의 답은 적히자마자 지워지겠지. 연습 DB의 주문은 안 움직이니까 0이 나온 거야." 선배가 말했다. "연습 DB의 정적인 데이터에서 '같다'를 운영에서 '맞다'로 읽지 마. Q2에는 안 붙여. 운영 주문 적재가 얼마나 잦은지 모르니까." 나는 Q2 옆에 보류: 의존 테이블(주문)의 커밋 빈도라고 적었다.

다음은 예상 보정액이었다. 같은 10만 행, 같은 보정률 함수를 네 가지 방식으로 불렀다.

예상 보정액 (정산 10만 행 · 고객 2,000명)함수 본문경과논리 읽기
그냥 함수 (행마다)100,00039~43cs300,549
스칼라 서브쿼리로 감싸기 (← 07화 메모판)57,66423~26cs173,541
DETERMINISTIC57,66423~25cs173,541
RESULT_CACHE 첫 판 / 두 번째 판14,467 / 12,55116~18cs43,950 / 38,202

스칼라 서브쿼리와 DETERMINISTIC이 57,664로 똑같았다. 07화의 메모판은 칸 수가 정해져 있어서 부딪히면 적지 않는데, 두 장치의 칸 수가 같은 것이다. 15화에서 2,000명을 다 못 담던 바로 그 메모판이었다.

RESULT_CACHE가 가장 적게 불렀다. 그런데 이상했다. 두 번째 판에도 12,551번이었다. 이미 한 번 2,000명을 다 계산했으니 두 번째는 0이어야 했다.

"보드에 칸이 열두 개뿐이라고 했지." 선배가 말했다.


오후 1시 40분 — 오라클의 보드에도 칸이 있다 ​

DBMS_RESULT_CACHE.MEMORY_REPORT로 결과 캐시의 메모리 보고를 찍었다. 연습 DB의 sqlp에는 이걸 부를 권한이 없어서 SYS로 실행 권한을 하나 받았다. 15화의 DBMS_HPROF 때와 같은 방식이었다.

Block Size          = 4K bytes
Maximum Cache Size  = 7936K bytes (1984 blocks)
Maximum Result Size = 196K bytes (49 blocks)
....... Cache Memory = 7936K bytes (1984 blocks)
........... Used Memory = 1974 blocks
............... Results = 1970 blocks
................... PLSQL   = 1970 blocks (1970 count)

칸은 1,984개였다. 그리고 PL/SQL 함수의 답 하나가 칸 하나를 차지했다. 보정률은 숫자 하나인데 4KB짜리 칸을 통째로 썼다. 답 1,970개가 1,970칸을 차지했으니 고객 2,000명은 들어가지 않았다. 새로 들어오는 답이 먼저 들어온 답을 밀어냈다. 밀려남(Delete Count Valid)이 두 번째 판에만 12,551번이었다. 칸에 못 든 고객은 나올 때마다 다시 계산됐다.

고객을 1,500명으로 줄여서 다시 쟀다.

RESULT_CACHE 1,500명  첫 판     본문 1,500   밀려남 0     8 cs
RESULT_CACHE 1,500명  두 번째   본문     0   찾기 75,000   7 cs
그냥 함수   1,500명            본문 75,000               29 ~ 32 cs
옆 세션이 같은 1,500명을 물으면  본문     0   같은 답 55,294,096

두 번째 판은 0, 옆 세션도 0이었다. 1,500명이면 보드에 다 들어갔다.

"화면 세션이 200개여도 등급표는 한 벌이네요. 15화처럼 패키지 변수에 두면 200벌이었는데." 내가 말했다. "그런데 연습 DB의 이 크기로는 2,000명이 안 들어가요."

"그게 오후의 첫 번째 숫자야. 운영의 결과 캐시 크기를 모르면 보정률 함수에 다시 붙일 수 없어." 선배가 말했다. "그리고 하나 더 봐 둬. 칸이 모자라는데도 결과 캐시 판이 제일 빨랐어. 16~18cs. 다만 '두 번째는 0'이라는 기대는 틀렸지. 캐시는 무조건 빨라지는 물건이 아니라, 담을 수 있는 양이 정해진 물건이야."

DETERMINISTIC이 호출을 줄이는 범위도 마저 쟀다. 같은 함수, 같은 10만 행에서 부르는 자리만 바꿨다. PL/SQL에서 직접 부르면 100,000번으로 하나도 안 줄었다. 커서 루프로 100행씩 받으면 99,938번, 집계 한 번(한 번의 페치)이면 57,664번이었다. SQL*Plus로 받으면서 ARRAYSIZE를 15, 500, 5,000으로 바꾸니 100,000, 97,991, 86,143번이었다. 한 번에 받는 행이 많을수록 줄었다. DETERMINISTIC의 기억은 한 번의 페치가 끝나면 사라졌다. 저장소가 아니었던 것이다.

결과 캐시의 칸, 한 결과의 상한, DETERMINISTIC의 범위는 2_파고들기.md §3 · §6에 있다.


오후 2시 20분 — 지우는 규칙 ​

"보정률 함수에 다시 붙이려면 지우는 규칙을 다 알아야 해." 선배가 말했다. "등급이 바뀌는 순간, 바뀐 걸 모르는 세션이 옛 보정률을 받으면 15화의 사고가 이번엔 캐시 쪽에서 나는 거야."

옆 세션을 하나 띄워 두고, 고객 마스터 복사본을 고쳐 가며 Q2와 보정률의 답을 하나씩 흔들어 봤다. 고객 한 명(CUS_000002)의 등급을 C에서 A로 바꾸고 커밋하지 않은 채 양쪽에서 물었다.

커밋 전  옆 세션   논리 읽기     0   답 A 10000 / 2564322100   ← 옛 답 (남의 변경은 아직 안 보인다 — 그게 맞다)
커밋 전  내 세션   논리 읽기 5,867   답 A 10500 / 2688614600   ← 캐시를 안 쓰고 다시 계산 · 결과는 Published 그대로
커밋 뒤  결과 Invalid → 옆 세션 5,868 (다시 계산 · 새 답) → 그다음 내 세션 0
고객 마스터에 한 일결과 캐시
고치고 커밋하지 않음남의 세션은 캐시(옛 답) · 내 세션은 캐시를 건너뛰고 다시 계산
고치고 커밋Invalid. 다음에 묻는 쪽이 다시 계산
고치고 ROLLBACKPublished 그대로(논리 읽기 0)
같은 값으로 덮어쓰고 커밋(CUS_GD = CUS_GD)Invalid. 값이 아니라 '고쳐졌다'는 사실로 지운다
고객 한 명의 등급을 고쳐 커밋보정률 답 1,500칸 전부 Invalid → 다음 판 본문 1,500
상관없는 테이블(정산)을 고쳐 커밋Published 그대로
함수를 다시 컴파일그 함수의 답 전부 Invalid
마스터에 없는 고객 다섯 번본문 다섯 번. NO_DATA_FOUND는 저장되지 않는다

"한 명을 고쳤는데 1,500명이 다 지워져요."

"보드 얘기로 하면, 규정집의 한 줄이 바뀌어도 그 규정집에 기대는 칸을 전부 지우는 거야. 어느 줄이 어느 칸인지는 안 따져. 테이블 단위로 기억해." 선배가 말했다. "그래서 결과 캐시는 기대는 테이블이 드물게 바뀔 때만 이득이야. 고객 등급은 분기마다 한 번 바뀌어. 규정은 분기마다 함수를 다시 만들 때 바뀌고, 그때 컴파일하면서 캐시도 같이 지워져. 보정률 함수는 그 조건에 맞아."

RELIES_ON도 써 봤다. 예전 버전에서는 함수가 기대는 테이블을 이 절에 손으로 적었다고 했다. 컴파일하자 경고가 떴다. PLW-05019: the language element near keyword RELIES_ON is deprecated beginning with version 11.2. 아침에 본 의존 객체 목록에 고객 마스터가 들어 있던 것도 그래서였다. 오라클이 스스로 찾아 적어 둔 것이다.

무효화 실측 전부와 서브캐시 둘, RELIES_ON은 2_파고들기.md §4에 있다.


오후 3시 10분 — 전부 켜면 ​

"힌트를 하나하나 붙이는 게 문제면, 아예 전부 켜면 안 돼요? RESULT_CACHE_MODE를 FORCE로요."

"재 봐. 단, 네 세션에서만." 선배가 말했다. "ALTER SYSTEM은 안 돼. 연습 DB라도."

ALTER SESSION SET RESULT_CACHE_MODE = FORCE를 실행했다. 힌트 없는 Q2가 바로 캐시됐다. 실행계획에 RESULT CACHE가 생기고, 두 번째 실행에서는 아래 줄 Starts가 0이었다. 여기까지는 좋았다. 그다음부터는 아니었다.

                                       MANUAL                         FORCE
한 번 묻고 마는 질문 2,000개 (PK 조회)   결과 캐시 안 건드림                새로 만듦 2,001 · 밀어냄 1,011 ~ 1,018
함수 안의 SQL (보정률 10만 행) 첫 판      39 ~ 41 cs                      61 ~ 69 cs · 만듦 75,587 · 밀어냄 74,597
                              두 번째   39 ~ 41 cs                       0 cs   ← 바깥 문장째 캐시됐다
고치고 읽기 1,000번 (채번 복사본)        만듦 0                           만듦 1,000 · 찾음 0 · 무효화 999
'초' 판을 부르는 문장 (힌트 없음)        시각이 간다                       시각이 멈춘다

한 번 묻고 마는 조회가 칸을 채우며 다른 답을 밀어냈다. 함수 안의 SELECT 한 줄 한 줄까지 캐시에 넣으려다 첫 판이 1.5배 넘게 느려졌다. 매번 바뀌는 채번 테이블은 만들자마자 지워졌다. 1,000번 만들고 한 번도 못 찾았다. Q2를 운영에 붙이면 생길 모습이 바로 이것이었다.

그리고 마지막 줄을 봤다. 힌트 한 줄 없이 시계가 멈췄다.

"FORCE면 금요일 사고가 힌트도 없이 나요." 내가 말했다. "아무도 RESULT_CACHE를 안 썼는데도요."

"MANUAL에서는 붙이는 사람이 '이건 같은 답이다'를 책임져. 금요일의 나처럼, 틀리면 틀린 사람이 있지." 선배가 말했다. "FORCE에서는 고르는 사람이 없어져. 좋은 문장도, 한 번 묻고 마는 문장도, 시계를 숨긴 함수를 부르는 문장도 똑같이 대접받아. 그리고 아무도 책임을 안 지지."

안심이 되는 점도 하나 있었다. FORCE가 바깥 문장째 캐시한 보정 합계의 의존 객체에는 정산 복사본과 함수, 그리고 함수가 안에서 읽은 고객 마스터까지 잡혀 있었다. 고객 한 명의 등급을 고쳐 커밋하자 그 답은 지워졌고, 다시 계산한 합계 73,741,702는 MANUAL로 잰 값과 같았다. 테이블에 기대는 답은 테이블이 바뀔 때 지워지니 안전하다. 시계에 기대는 답은 아무도 지켜 주지 않는다.

세션을 MANUAL로 되돌리고, 시스템 값이 처음부터 MANUAL인 것을 확인했다.

RESULT_CACHE_MODE 실측과 바깥 문장의 의존 객체는 2_파고들기.md §7에 있다.


오후 3시 50분 — 보드 앞이 좁다 ​

"보드가 하나니까, 화면 200개가 동시에 보정률을 물으면 어떻게 돼요?"

"07화 탕비실 선반 안내문 기억나? 칸에 손을 넣기 전에 집게를 집으라던 거." 선배가 말했다. "결과 캐시도 공유 메모리야. 찾을 때마다 그 메모리를 지키는 래치를 잡아. Free는 CPU가 2개라 200명은 못 만들어. 둘까지는 돼."

결과 캐시에 이미 올라 있는 보정률 하나를 100만 번 찾는 일꾼을 만들었다. 대조군은 캐시도 SQL도 없는 PL/SQL 함수를 100만 번 부르는 것이다. 혼자 돌린 뒤, 옆 세션과 둘이 같은 순간에 출발시켰다.

                             혼자          둘이 동시에 (각자)
캐시 없는 함수 100만 번         7 ~ 8 cs      8 ~ 9 cs
결과 캐시에서 찾기 100만 번     20 ~ 21 cs    34 ~ 42 cs
  Result Cache: Set            misses 0     misses 15,445 ~ 58,104   sleeps 0
  Result Cache: Blocklist Latch misses 0     misses  7,681 ~ 58,381   sleeps 0

혼자 찾을 때도 그냥 함수보다 세 배 느렸다. 찾기 한 번마다 래치 두 개를 한 번씩 잡았다(gets 1,000,001). 둘이 같은 답을 동시에 찾자 각자의 시간이 두 배 가까이 늘었다. 래치를 놓친 횟수는 수천에서 수만 번이었고, 잠든 횟수는 0이었다. 놓치면 그 자리에서 돌면서 다시 잡았다는 뜻이다. 돌며 기다리는 것도 CPU를 쓴다. 캐시 없는 함수는 둘이 돌려도 그대로였다.

"보드 앞이 좁은 거야. 한 사람이 들여다보는 동안 옆 사람은 어깨 너머로 기다려." 선배가 말했다. "화면 200개가 같은 한 칸을 두드리면 어떻게 되는지는 이 연습 DB로는 못 봐. 대규모 환경은 별권이야. 여기서 가져갈 건 하나야. 결과 캐시에서 '찾는' 일도 공짜가 아니다. 07화에서 메모리 읽기도 일이었던 것처럼."

경합 실측, 래치, Free의 한계는 2_파고들기.md §8에 있다.


오후 4시 30분 — 고친다 ​

정산일 함수로 돌아왔다. 아침에 되돌린 건 2019년 원본이었다. 답은 맞고, 목록에서도 한 번만 불린다. 그런데 DETERMINISTIC이라는 거짓 약속이 그대로 남아 있었다.

"원본으로 되돌렸으니까 끝난 거 아니에요?"

"다음 사람이 그 약속 위에 또 뭘 쌓을지 몰라. 금요일의 나처럼." 선배가 말했다. "그리고 하나 더. 이 함수, 시험할 수 있어?"

생각해 봤다. 18시 마감을 시험하려면 17시 59분 59초와 18시 정각에, 그리고 금요일 밤과 월요일 아침에 불러 봐야 한다. 그런데 이 함수는 인자가 없어서 시각을 넣을 구멍이 없었다. 시험하려면 그 시각까지 기다리거나 시계를 돌려야 했다.

"…못 해요. 숨은 입력이라서요."

"그럼 꺼내." 선배가 말했다. "아까 그 넷은 다 '인자로 안 들어오는 입력'이었지. 인자로 들어오게 바꾸면 돼."

SDT_rev01을 썼다. 정산일 함수의 첫 리비전이다. 15화의 ADJ처럼, 6조각이 아닌 함수 하나를 위한 줄이 새로 생겼다.

sql
-- SDT_rev01
FUNCTION FN_STLM_DT(p_at DATE DEFAULT SYSDATE) RETURN DATE IS    -- RESULT_CACHE 없음 · DETERMINISTIC 없음
BEGIN
  RETURN TRUNC(p_at + 6/24);
END;

-- 목록은 부르는 쪽이 '문장마다 한 번' 을 정한다
WHERE STLM_DT <= (SELECT FN_STLM_DT FROM dual)

인자 없이 부르면 '지금'이 들어가므로 기존 호출부는 그대로 돈다. 시험할 때는 시각을 넣는다. 두 단어는 모두 뗐다. 목록에서 한 번만 부르는 일은 함수의 약속 대신 부르는 쪽의 스칼라 서브쿼리가 맡았다. 07화의 메모판이다. 한 문장 동안만, 그 문장 혼자 쓴다.

"'지금'이 얼마 동안 유효한지는 함수가 모르는 거야." 선배가 말했다. "한 문장 동안인지, 한 화면 동안인지, 배치 한 판 동안인지는 부르는 업무가 알아. 그걸 함수 머리에 박아 두면 안 돼."

그리고 처음으로 이 함수를 시험했다. 원장으로는 규칙을 그대로 옮긴 식(18시 이후면 다음 날, 아니면 그날)을 썼다.

시각                    원장         rev01        rev00 (금요일 판)
06-14 FRI 17:59:59     06-14 FRI    06-14 FRI    D (now)
06-14 FRI 18:00:00     06-15 SAT    06-15 SAT    D (now)
06-14 FRI 23:59:59     06-15 SAT    06-15 SAT    D (now)
06-15 SAT 00:00:00     06-15 SAT    06-15 SAT    D (now)
06-16 SUN 18:30:00     06-17 MON    06-17 MON    D (now)
06-17 MON 09:00:00     06-17 MON    06-17 MON    D (now)
06-17 MON 18:00:00     06-18 TUE    06-18 TUE    D (now)

한 주를 7분 간격 1,440 점으로   rev01 MINUS 원장 0 · 원장 MINUS rev01 0

일곱 경계가 모두 원장과 같았다. 한 주를 1,440점으로 훑어도 MINUS 0 / 0이었다. 금요일 판은 시각을 받을 구멍이 없어서 일곱 줄 모두 같은 값 하나, '지금'을 내놓았다. 금요일의 대조가 왜 통과했는지가 그 열에 다 드러나 있었다.

목록 SQL에서 rev01도 쟀다. 정산일 본문 1번, 경과 0cs, 논리 읽기 549로 2019년 원본과 같았다. 이 리비전은 블록을 한 개도 줄이지 않았다. 고친 건 답이었다.

계획 해시도 봤다. 그냥 함수 판, 2019 원본, 금요일 판이 셋 다 3721302985로 계획이 똑같았다. 실행계획의 4수치로도 차이가 안 보였다. TABLE ACCESS FULL, Starts 1, Buffers 549, filter("STLM_DT"<="FN_STLM_DT_RC_C15B"())까지 같았다. 08화 체크리스트 F의 내용 그대로였다. 계획 해시가 같아도 답은 다를 수 있다.

선배가 화면을 한참 보다가 입을 열었다.

"이 화의 제목 알지. 같은 답을 두 번 계산하지 마라. 그건 반쪽이었어. 앞에 한 줄이 더 있어야 해. 그게 정말 같은 답인지부터 물어라. 금요일의 나는 '같은 질문'인지를 인자만 보고 판단했어. 질문에는 인자로 안 들어오는 것도 들어 있었는데."

⛳ 오늘은 여기까지(중단 지점 2)SDT_rev01이 나왔다. 숨은 입력(시각)을 인자로 꺼냈고, RESULT_CACHE와 거짓 DETERMINISTIC을 뗐고, '문장마다 한 번'은 부르는 쪽이 정하게 했다. 일곱 경계 · 1,440점 MINUS 0 / 0. Buffers는 549 그대로다. 이 리비전의 효과는 속도가 아니라 답이다. 오늘 몫은 여기까지만 해도 다 한 것이다. 이어지는 저녁에는 T1 11호를 쓰고, 선배가 원인 칸에 무엇을 적으라고 하는지 본다.


오후 5시 40분 — T1 11호 ​

T1 양식을 열었다. 10호는 13화의 토요일 장애였다. 11호는 오늘의 사건이었다.

1번 대상 칸에는 두 줄을 적었다. 정산일 함수 FN_STLM_DT: SDT_rev00 → rev01. 보정률 함수 FN_ADJ_RATE의 RESULT_CACHE: 되돌림, 재적용 보류. 4번 현상 칸에는 4수치를 그대로 옮기고, 그 아래에 한 줄을 덧붙였다. 4수치로는 보이지 않는다. 금요일 판과 캐시 없는 판의 계획, Buffers, 계획 해시가 같다.

5번 원인 칸에서 멈췄다. 어떻게 써야 할지 몰랐다.

"써." 선배가 옆에서 말했다. "6/14 윤세라: 시계에 기대는 함수에 RESULT_CACHE를 붙임. 판단 근거는 작성자의 DETERMINISTIC 표시. 대조는 같은 날 오후 안에서만 함(시각 경계 없음). 그대로 적어."

"선배님 이름을요?"

"강도현 씨 커밋 로그에는 늘 이름이 있었지. 그래서 우리가 그때의 근거를 읽을 수 있었어." 선배가 말했다. "내 근거도 남아야 해. 다음 사람이 내 판단을 무작정 믿지 않게."

8번 정합성 칸에는 08화 체크리스트를 붙였다. A의 기준은 원장, 즉 규칙을 그대로 옮긴 식이다. 직전 판인 금요일 판과 대조해서는 아무것도 보이지 않았다. E 경계에는 오늘 새로 생긴 줄을 적었다. 시각: 18시 정각 · 자정 · 금요일 → 월요일. 세션: 다른 세션 · 다른 시간대 · 다른 NLS · 다른 직원. F 계획 해시에는 3721302985, 셋이 같다를, G에는 '오라클이 자동으로 무효화한다'를 근거로 쓰지 않는다. 무효화는 의존 객체만 본다를 적었다.

9번 환경 의존성에는 연습 DB의 보드 크기를 적었다. 결과 캐시 7,936KB = 4KB 칸 1,984개 · 함수의 답 하나 = 칸 하나 · 고객 2,000명이 안 들어간다. 12번 재측정 계획의 첫 줄은 운영의 결과 캐시 크기 확인이었다. 그게 확인돼야 보정률 함수에 결과 캐시를 다시 붙일지 정할 수 있다. 그리고 한 줄을 더 적었다. 운영에서 RESULT_CACHE가 붙은 함수 전수 목록(USER_PROCEDURES.RESULT_CACHE = 'YES') · 그 함수들이 시계, 세션 설정, 패키지 변수를 읽는가.

11화에 만든 「SQL 코딩 가이드」에도 조문 11을 올렸다. 근거는 1회다.

11  결과 캐시(RESULT_CACHE · /*+ RESULT_CACHE */ · FORCE)는 답이 '테이블과 인자' 에만 기대는 곳에만 붙인다.
    시계 · 세션 시간대 · NLS · 세션 상태 · 패키지 변수에 기대는 함수에는 붙이지 않는다 — 숨은 입력은 인자로 꺼낸다.
    DETERMINISTIC 은 아무도 검사하지 않는 약속이다. 거짓이면 쓰지 않는다.

퇴근 전에 두 사람에게 메시지를 보냈다.

나  18:30
금요일부터 오늘 아침까지 정산일이 6/14 로 고정된 원인을 찾았습니다.
정산일 함수가 시계를 읽는데, 금요일에 '같은 답을 기억해 두는' 기능(결과 캐시)이 붙으면서
처음 계산한 날짜를 계속 돌려줬습니다. 이 기능은 테이블이 바뀌면 기억을 지우지만, 날짜가 바뀌는 것은 모릅니다.
오늘 9시 40분에 원래 함수로 되돌렸고, 시각을 넣어 시험할 수 있게 고친 판을 연습 서버에서 확인했습니다.
금요일 저녁 6시 이후 등록분부터 정정 대상이고, 목록은 개발팀이 뽑고 있습니다.
조민우  18:41   보드 칸에 규칙 적은 거 그대로 둘게요. 정정은 내일 오전까지 끝낼게요.
박팀장  18:52   빠르게 하려다 날짜가 틀렸다는 거군요. 고친 판은 배포 전에 저도 한 번 보겠습니다.

박팀장의 마지막 문장을 두 번 읽었다. 02화에서 처음 받은 메시지는 "그래서 언제쯤 빨라지나요?"였다.


오늘 남은 것 ​

무엇값
사건6/14 17:30 ~ 6/17 09:40 정산 등록분의 정산일이 6/14로 고정(금요일 판 정산일 함수: DETERMINISTIC + RESULT_CACHE)
원인정산일 답의 의존 객체 = 함수 자신 하나. 진짜 입력(시계)은 의존 객체가 아니어서 아무도 안 지운다
재현 (시계는 안 바꿨다)'초' 판 2초 간격: 첫 값에 멈춤 · 목록 100,001 vs 100,000(MINUS 1/0) · 시간대 -11:00 → +13:00: 어제의 답을 오늘도 · 다른 세션 · NLS 형식 · 세션의 직원까지
한 단어가 번 것목록 정산일 본문 100,000(그냥) → 1(2019 DETERMINISTIC) → 0 · 찾기 1(금요일 판). 없었다
약속문장에 SYSDATE를 쓰면 결과 캐시를 거절 · 함수에 숨기면 받아 줌 · ENFORCED도 DETERMINISTIC은 믿는다
Server Result CacheQ2 5,868 × 10 = 58,685 → 5,867 + 0 × 9 · 옆 세션 0. 연습 DB의 정적인 주문에서만 그렇다. Q2 보류
PL/SQL Function Result Cache예상 보정액 100,000 / 57,664(스칼라 · DETERMINISTIC) / 14,467 → 12,551. 칸 1,984 · 답 하나 = 칸 하나 · 1,500명이면 → 0
무효화커밋된 변경만 · 값이 같아도 · 고객 한 명에 1,500칸 전부 · 컴파일 · 예외는 저장 안 함
FORCE한 번 묻는 조회가 칸을 밀어냄 · 함수 안 SQL 첫 판 39~41 → 61~69cs · 고치고 읽기 1,000 = 만듦 1,000 · 찾음 0 · 힌트 없이 시계가 멈춤
경합찾기 100만 번 혼자 20~21cs → 둘이 34~42cs(각자) · 래치 misses 수천 ~ 수만 · sleeps 0
SDT_rev01시각을 인자로 · 두 단어를 뗌 · 부르는 쪽 스칼라 서브쿼리 · 일곱 경계 · 1,440점 MINUS 0/0 · Buffers 549 → 549
T1 11호정합성 체크리스트 E에 '시각 · 세션' 경계 · 원인 칸에 6/14의 판단 근거
SQL 코딩 가이드조문 11(결과 캐시) 〔근거 1회〕

오늘 배운 한 문장 ​

같은 답을 두 번 계산하지 마라. 다만 그 전에, 그게 정말 같은 답인지부터 물어야 한다. 결과 캐시는 인자와 테이블만 보고 '같은 질문'인지 판단한다. 시계, 세션, 설정처럼 인자로 들어오지 않는 입력이 있으면 다른 질문에 같은 답을 준다. 그리고 DETERMINISTIC은 캐시가 아니라, 아무도 검사하지 않는 약속이다.

다음으로 넘기는 것 ​

  • 처음으로 시스템 전체를 본다: Q2는 결과 캐시 없이 rev01 그대로 다음 주의 부하에 들어간다 → 16화
  • 보정률 함수의 결과 캐시 재적용(운영 결과 캐시 크기와 고객 수를 확인한 뒤, T1 11호 12번) · 결과 캐시를 붙인 함수 전수 점검 · 「SQL 코딩 가이드」 조문 11의 승격 · 정합성 체크리스트 경계 목록의 '시각 · 세션' 줄 → 18화

다음 → 3_실습과_기록.md에서 얼어붙은 정산일을 직접 재현하고, 캐싱 4계층을 나란히 재고, T1 11호를 쓴다. 개념이 막히면 2_파고들기.md를 펼친다.