|
06. 페이지 처리의 중요성
- 클라이언트/서버 환경에서 대용량 데이터를 조회할 때는 커서를 닫지 않은 채 사용자 이벤트가 발생할 때마다 결과 집합을
Fetch 하도록 구현 가능.
- 웹 애플리케이션 환경에서는 커서를 계속 오픈한 채로 결과집합을 핸들링할 수 없다.
페이지 처리 해결 방법
- 페이지 처리를 서버 단에서 완료하고 최종적으로 출력할 레코드만 Fetch 하도록 프로그램을 고치는 것이다.
< 페이지 처리를 하지 않았을 때 발생하는 부하요인 >
1. 다량의 Fetch Call 발생
2. 대량의 결과 집합을 클라이언트로 전송하면서 발생하는 네트워크 부하
3. 대량의 데이터 블록을 읽으면서 발생하는 I/O부하
4. AP 서버 및 웹 서버 리소스 사용량 증가
< 페이지 처리에 따른 영향 >
1. 페이지 단위로, 화면에서 필요한 만큼씩 Fetch Call
2. 페이지 단위로, 화면에서 필요한 만큼씩 네트워크를 통해 전송
3. 인덱스와 부분범위처리 원리를 이용해 각 페이지에 필요한 최소량만 I/O
4. 데이터를 소량씩 나누어 전송하므로 AP, 웹 서버 리소스 사용량 최소화.
-> 결과적으로 조회할 데이터가 일정량 이상이고 수행빈도가 높다면 필수적으로 페이지 처리를 구현해야 한다.
<페이지 처리 목적>
- 데이터베이스 Call 횟수를 줄이고 네트워크를 통한 데이터 전송량을 최소화
- 서버 내에서의 처리 일량을 줄인다.
07 PL/SQL 함수의 특징과 성능 부하
(1) PL/SQL 함수의 특징
- PL/SQL로 작성한 함수와 프로시저를 컴파일하면 JAVA 언어처럼 바이트코드가 생성되며, 이를 해석하고 실행할 수 있는 PL/SQL
엔진만 있다면 어디서든 실행될 수 있다. 바이트코드는 데이터 딕셔너리에 저장되었다가 런타임 시 해석된다.
- PL/SQL은 인터프리터 언어이므로 그것으로 작성한 함수 실행 시 매번 SQL 실행엔진과 PL/SQL 가상머신 사이에 스위칭이 일어
난다.
- SQL에서 함수를 호출할 때마다 SQL 실행엔진이 사용하던 레지스터 정보들을 백업했다가 PL/SQ 엔진이 실행을 마치면 다시
복원하는 작업을 반복하게 되므로 느려질 수 밖에 없다.
(2) RECURSIVE CALL를 포함하지 않는 함수의 성능 부하
-> 오라클 내장 함수 to_char와 사용자 정의 함수를 사용할 때의 수행시간을 비교
-> 서버 사양에 따라 다른데, Recursive Call 없이 컨텍스트 스위칭 효과만으로 보통 5~10배 정도 느려진다.
(3) Recursive Call를 포함하는 함수의 성능 부하
- 대개의 사용자 정의 함수에는 Recursive Call을 포함한다.
- Recursive Call도 매번 Execute Call과 Fetch Call을 발생시키기 때문에 대량의 데이터를 조회하면서 레코드 단위로 함수를
호출하도록 쿼리를 작성하면 성능이 극도로 나빠진다.
<위에서 사용했던 date_to_char 함수에 DUAL 테이블을 읽는 간단한 select문 하나 삽입>
CREATE OR REPLACE FUNCTION DATE_TO_CHAR(P_DT DATE) RETURN VARCHAR2
AS
L_EMPNO NUMBER;
BEGIN
SELECT 1 INTO N FROM DUAL;
RETURN TO_CAHR(P_DT, 'YYYY/MM/DD HH24:MI:SS');
END;
/
- I/O가 전혀 발생하지 않는 가벼운 쿼리를 삽입했을 뿐인데, Recursive Call 없는 함수와 비교하면 4배, 함수를 사용하지 않았을
때와 비교하면 23배가량 더 느려졌다.
--> 대용량 조회 쿼리에서 함수를 남용하면 읽는 레코드 수만큼 건건이 함수 호출이 발생해 성능이 극도로 나빠진다. 따라서 사용자
정의 함수는 소량의 데이터 조회 시에만 사용하거나, 대용량 조회 시에는 부분범위처리가 가능한 상황에서 제한적으로 사용해야
한다. 그리고 성능을 위해서라면 가급적 조인 또는 스칼라 서브쿼리 형태로 변환하려고 노력해야 한다.
(4) 함수를 필터 조건으로 사용할 때 주의 사항
- 조건절과 인덱스 상황에 따라 함수 호출 횟수가 달라지므로 주의.
CREATE OR REPLACE FUNCTION EMP_AVG_SAL RETURN NUMBER
IS
L_AVG_SAL NUMBER;
BEGIN
SELECT AVG(SAL) INTO L_AVG_SAL FROM EMP;
RETURN L_AVG_SAL;
END;
/
CREATE INDEX EMP_X01 ON EMP(SAL);
CREATE INDEX EMP_X02 ON EMP(DEPTNO);
CREATE INDEX EMP_X03 ON EMP(DEPTNO, SAL);
CREATE INDEX EMP_X04 ON EMP(DEPTNO, ENAME, SAL);
<케이스 1>
- 아래처럼 인덱스를 사용하지 않고 Full Scan 할 때는, 스캔하면서 읽은 전체 건수만큼 함수 호출이 일어난다.
<케이스 2>
-> sal 컬럼을 선두로 갖는 인덱스를 이용하도록 하면 함수 호출이 단 한번만 일어난다.
SELECT /*+ INDEX(EMP (SAL)) */ *
FROM EMP
WHERE SAL >= EMP_AVG_SAL;
<케이스 3>
- 조건절에 'DEPTNO = 20' 조건을 하나 더 추가하고, DEPTNO 컬럼 하나만으로 구성된 EMP_X02 인덱스를 이용.
<케이스 4>
- DEPTNO + SAL 순으로 구성된 EMP_XO3 인덱스를 사용.
- 'SAL >=' 조건까지 인덱스 액세스 조건으로 사용되므로 함수 호출이 1번만 발생
<케이스 5>
- 조건절은 같은데, DEPTNO와 SAL 컬럼 중간에 ENAME 컬럼이 낀 EMP_X04 인덱스를 사용
-> 'SAL>=' 조건이 인덱스 액세스 조건으로 사용되지만 ENAME 조건이 없는 상황이어서 필터 조건으로도 사용되는 것을 볼 수
있다. 따라서 인덱스를 스캔할 첫 번째 레ㅗ드 액세스 단계에서 1번, 필터 단계에서 나머지 4건을 찾는 동안 4번, 'DEPTNO=20'
범위를 넘어 더 이상 조건을 만족하는 레코드가 없음을 확인하는 ONE-PLUS 스캔과정에서 1번, 그래서 총 6번의 함수 호출.
<케이스 6>
SELECT /* INDEX(emp(DEPTNO, SAL)) */ *
FROM EMP
WHERE SAL >= EMP_AVG_SAL
AND DEPTNO >= 10;
- 앞에서는 선행 컬럼이 누락된 경우를 보았고, 케이스 6은 '=' 조건이 아닌 경우다. 인덱스 컬럼 구성상 선행 컬럼이 조건절에 누락
되거나 '=' 조건이 아닌 경우 그 컬럼은 필터 조건으로 사용된다.
(5) 함수와 읽기 일관성
CREATE TABLE LOOKUPTABLE(
KEY NUMBER,
VALUE VARCHAR2(100)
);
INSERT INTO LOOKUPTABLE VALUES(1, 'YAMAHA');
COMMIT;
CREATE OR REPLACE FUNCTION LOOKUP(1_INPUT NUMBER) RETURN VARCHAR2
AS
L_OUTPUT LOOKUPTABLE.VALUE%TYPE;
BEGIN
SELECT VALUE INTO L_OUTPUT
FROM LOOKUPTABLE
WHERE KEY = L_INPUT;
RETURN L_OUTPUT;
END;
/
-> LOOKUP 함수를 참조하는 쿼리가 잇다고 하자. 그 쿼리를 수행하고 결과 집합을 FETCH 하는 동안 다른 세션에서 LOOKUPTABLE로부터 VALUE 값을 변경한다면 레코드를 FETCH 하면서 LOOKUP 함수가 반복 호출되는데, 중간부터 다른 결과
값을 리턴하게 된다.
-> 문장수준 읽기일관성 보장 X, 함수 내에서 수행되는 RECURSIVE 쿼리는 메인 쿼리의 시작 지점에 무관하게 그 쿼리가 수행되는
시점을 기준으로 블록을 읽기 때문에 생기는 현상이다.
CREATE OR REPLACE SF_현재가(P_종목코드 VARCHAR2) RETURN NUMBER
AS
RVALUE NUMBER;
BEGIN
SELECT 현재가 INTO RVALUE FROM 종목별시세 WHERE 종목코드 = P_종목코드;
RETURN RVALUE;
END;
/
CREATE OR REPLACE SF_시가총액(P_종목코드 VARCHAR2) RETURN NUMBER
AS
RVALUE NUMBER;
BEGIN
SELECT 현재가 * 발행주식수 INTO RVALUE
FROM 종목별시세 WHERE 종목코드 = P_종목코드;
RETURN RVALUE;
END;
/
SELECT A.지수업종코드
, MIN(A.지수업종명) 지수업종명
, AVG(SF_현재가(B.종목코드)) 평균주식가격
, SUM(SF_시가총액(B.종목코드)) 시가총액
FROM 지수업종 A, 지수업종구성종목 B
WHERE A.지수업종유형코드 = '001'
AND B.지수업종코드 = A.지수업종코드
GROUP BY A.지수업종코드;
-> 주식 종목별 현재가와 시가총액은 수시로 변한다. 위와 같은 함수를 만들고, 이를 이용해 값을 집계하는 쿼리를 날린다면
일관성 없는 결과를 내기 쉽다.
※ 캐싱효과를 위해 Deterministic 함수로 선언하거나 아래처럼 함수에 스칼라 서브쿼리를 덧씌우더라도 이 문제를 완전히
해소할 수는 없다.
<아래처럼 일반 조인문 또는 스칼라 서브쿼리를 사용할 때만 완벽한 문장수준 읽기 일관성이 보장된다.>
-> 이런 읽기 일관성 문제는 프로시저, 패키지, 트리거를 사용할 때도 공통적으로 나타나는 현상이다. 오라클에서 트리거를 사용하면
데이터 정합성이 꺠진다는 얘기를 자주 듣는데, 사실은 오라클만의 독특한 읽기 일관성 모델을 정확히 이해하지 못한 상태에서
개발하기 때문에 생기는 현상이라고 봐야한다.
-> 함수/프로시저를 잘못 사용하면 성능을 떨어뜨릴 뿐 아니라 데이터 정합성까지 해칠 수 있으므로 주의해야 한다.
(6) 함수의 올바른 사용 기준
- 함수를 써서 오히려 성능을 향상시키는 사례 : 2장에서 설명 했던 채번 함수(seq_nextval)
-> 오라클 Sequence 오브젝트를 사용하지 않는 한, Lock 경합을 최소화하면서 이보다 더 빠르게 채번하는 방법은 없다.
- 함수/프로시저를 사용하지 않았을 때 결국 User Call을 발생시키도록 구현해야 한다면, 오라클 함수/프로시저를 사용하는 편이
더 나은 선택이다.
-- 반대로 모든 프로그램을 PL/SQL 함수와 프로시저로 구현하려 하는 것도 문제가 될 수 있다. 라이브러리 캐시에서 관리해야 할
오브젝트 개수와 크기가 늘어나면 아무래도 히트율이 떨어지고, 경합이 증가해 효율성이 저하되기 마련이다.
참고로, Dependency 체인에 의한 라이브러리 캐시 부하를 최소화하려면 가급적 함수/프로시저보다 패키지를 사용하는 것이 유리하다.
- 정해진 Shared Pool 크기 내에서 소화할 수 있는 적정개수의 SQL과 PL/SQL 단위 프로그램을 유지하도록 노력
--> 연산 위주의 작업은 애플리케이션 서버 단에서 주로 처리하고, SQL 수행을 많이 요하는 작업은 오라클 함수/프로시저를 이용하도록 설계
08 PL/SQL 함수 호출 부하 해소 방안
- 사용자 정의 함수는 소량의 데이터 조회시에만 사용하는 것이 좋다.
- 대용량 데이터를 조회할 때는 부분범위처리가 가능한 상황에서 제한적으로 사용.
- 조인 또는 스칼라 서브쿼리 형태로 변환하려는 노력이 필요
- 어쩔 수 없을 때는 함수를 쓰되 호출 횟수를 최소화할 수 있는 방법을 강구
< 함수 호출 부하 해소 방안 >
- 페이지 처리 또는 부분범위처리 활용
- Decode 함수 또는 Case문으로 변환
- 뷰 머지 방지를 통한 함수 호출 최소화
- 스칼라 서브쿼리 캐싱 효과를 이용한 함수 호출 최소화
- Deterministic 함수의 캐싱 효과 활용
- 복잡한 함수 로직을 풀어 SQL로 구현
(1) 페이지 처리 또는 부분범위처리 활용
-> 위처럼 쿼리를 작성하면 최종 결과 건수가 얼마건 간에 조건절에 부합하는 전체 레코드 건수만큼 함수 호출을 일으키고,
그 결과집합을 Sort Area 또는 Temp 테이블 스페이스에 저장한다. 그리고 최종 결과집합 10건만을 사용자에게 전송하게 된다.
< 아래 쿼리 중요 !>
- order by와 rownum에 의한 필터 처리 후 사용자에게 전송하는 최종 결과집합에 대해서만 함수 호출이 일어난다.
요즘 같은 n-Tier 환경에서는 페이지 처리가 필수다 보니 가장 흔히 접하게 되는 튜닝 사례이다.
-> 페이지 처리를 하지 않더라도 부분범위처리가 가능한 상황이라면 클라이언트에게 데이터를 전송하는 맨 마지막 단계에 함수
호출이 일어나도록 함으로써 큰 성능 개선을 이룰 수 있다.
(2) Decode 함수 또는 Case문으로 변환
- 함수가 안쪽 인라인 뷰에서 order by 절에 사용된다든가, 전체 결과집합을 모두 출력하거나, insert...select문에서
사용 된다면 다량의 함수 호출을 피할 수 없다. 그럴 때는 함수 로직을 풀어서 decode, case문으로 전환하거나 조인문으로 구현
할 수 있는지 먼저 확인해야 한다.
-- 함수를 이용해 상품분류별 집계
-> 13.42초가 걸렸다.
< CASE문 사용 >
-> 3.48초가 걸렸다.
<DECODE 사용>
-> 3.44초가 걸렸다.
< Recursive Call을 포함하여 분류순서 테이블을 쿼리하는 Recursive Call이 100만 번 수행>
< case문 또는 decode 함수를 사용한 쿼리가 같은 결과가 나오도록 넘버링하는 부분만 고쳐 수행 >
-> 3.02초 수행.
<함수를 사용하면 장점>
- 함수를 사용하면 분류체계가 바뀌더라도 SQL들을 찾아 일일이 바꾸지 않아도 된다. 함수 내용만 바꿔주면 되기 때문이다.
- 하지만 굳이 함수를 이용하지 않더라도 정보 분류 및 업무 규칙, 규정들을 테이블화해서 관리한다면 매번 쿼리를 바꾸지 않고도
함수가 갖는 장점들을 그대로 가져올 수 있다.
-> 0.89초 수행하였다.
-> 100만 건 그대로 조인을 실시하면 당연히 느려지겠지만 (해시 조인으로 처리하면 빠르다.) group by를 먼저 수행해 20건으로
압축된 결과집합을 가지고 조인하므로 성능을 전혀 떨어뜨리지 않는다.
(3) 뷰 머지(View Merge) 방지를 통한 함수 호출 최소화
- 어떤 이유에서건 함수를 풀어 조인문으로 변경하기 곤란한 경우가 분명히 있다. 그럴 때는 함수를 그대로 둔 채 함수 호출 횟수를
줄이려는 방법
< 함수 호출 횟수를 줄이려는 노력 안한 쿼리>
-> 117.68초 수행
-> 위 쿼리는 100만 건을 스캔하면서 SF_상품분류 함수를 3번씩 반복 수행하므로 총 300만 번 함수 호출이 일어난다.
< 뷰 머지 발생 >
-> 120.88 초 수행
< 뷰머지가 발생하지 못하도록 no_merge 힌트를 사용 >
-> 40.45초 수행
-- no_merge 힌트를 사용하지 않더라도 뷰 내에 rownum을 사용하면 옵티마이저는 절대 뷰 머지를 시도하지 않는다.
rownum을 포함하는 뷰를 메인 쿼리와 merge하면 결과가 틀릴 수 있기 때문이다.
-> 41.95초 수행
< 더 빨리 수행하기 위해 스칼라 서브쿼리의 캐싱 효과를 이용한다.>
(4) 스칼라 서브쿼리의 캐싱효과를 이용한 함수 호출 최소화.
- 스칼라 서브쿼리를 사용하면 오라클은 그 수행횟수를 최소화하려고 입력 값과 출력 값을 내부 캐시에 저장해 둔다.
스칼라 서브쿼리에 있어 입력 값은, 거기서 참조하는 메인 쿼리의 컬럼 값을 말한다.
-> 서브쿼리가 수행될 때마다 입력 값을 캐시에서 찾아보고 거기 있으면 저장된 출력 값을 리턴하고, 없으면 쿼리를 수행한 후 입력
값과 출력 값을 캐시에 저장해 두는 원리다.
-> 이 기능을 함수 호출 횟수를 줄이는 데 사용할 수 있는데, 함수를 Dual 테이블을 이용해 스칼라 서브쿼리로 한번 감싸는 것이다.
-> 특히, 함수 입력 값의 종류가 적을 때 이 기법을 활용하면 함수 호출횟수를 획기적으로 줄일 수 있다.
-- 시장코드와 증권그룹코드로 만들어질 수 있는 값의 조합이 20개이므로 SF_상품분류 함수에 대한 입력 값 종류도 20개다.
20개에 대한 입력 값과 출력 값을 캐싱한다면 함수 호출횟수를 20번으로 줄일 수 있다.
-> 30.50초 수행
<스칼라 서브쿼리를 이용한 트레이스>
-> 함수 호출 횟수를 20번으로 예상했지만 725003수행 하였다.
해시 충돌이 발생했기 때문이다. 해시 충돌이 발생하면 기존 엔트리를 밀어내고 새로 수행한 입력 값과 출력 값으로 대체할 것
같지만, 오라클은 기존 캐시 엔트리를 그대로 둔 채 스칼라 서브쿼리만 한 번 더 수행하고 만다. 따라서 해시 충돌이 발생한 입력
값이 반복적으로 입력되면 스칼라 서브쿼리를 사용하기 전처럼 여전히 쿼리가 반복 수행되기 때문에 위와 같은 현상이 발생하는
것이다.
10g에서는 입력과 출력 값 크기, _query_execution_cach_max_size 파라미터에 의해 캐시 사이즈가 결정 된다고 한다.
-> 1.52초 수행.
<캐시 사이즈 수정 후 트레이스 출력>
-> 이처럼 insert...select문이거나 부분범위처리 활용 없이 전체 데이터를 출력해야 하는 상황에서 함수 호출 때문에 성능이 크게
떨어진다면 스칼라 서브쿼리를 활용함으로써 성능을 획기적으로 개선할 수 있다.
<페이지 처리 또는 부분범위 처리 활용에 스칼라 서브쿼리 활용>
-> 이 기법은 입력 값의 종류가 소수여서 해시 충돌 가능성이 적은 함수에만 적용해야 하며, 그러지 않을 경우 도리어 CPU 사용률만
높이게 되므로 먼저 원리를 충분히 이해하고 나서 효과가 확실한 경우에만 사용하기 바란다.
(5) Deterministic 함수의 캐싱 효과 활용
- 10gR2에서 함수를 선언할 때 Deterministic 키워드를 넣어 주면 스칼라 서브쿼리를 덧입히지 않아도 캐싱 효과가 나타난다.
함수의 입력 값과 출력 값은 CGA(Call Global Area)에 캐싱된다. CGA에 할당된 값은 데이터베이스 Call 내에서만
유효하므로 Fetch Call이 완료되면 그 값들은 모두 해제된다. 따라서 Deterministic 함수의 캐싱 효과는 데이터베이스 Call내에서
만 유효.
반면, 스칼라 서브쿼리에서의 입력, 출력 값은 UGA에 저장되므로 Fetch Call에 상관없이 그 효과가, 캐싱되는 순간부터
끝까지 유지된다.
위처럼 1부터 함수 입력 값까지의 누적 합을 구하는 함수를 Determinitic으로 선언하고 컴파일하였다. 나중에 함수 호출 횟수를
확인할 목적으로 세션 client_info 값을 매번 변경하는 코드를 중간에 삽입하였다.
-- 100만번 함수 호출
--실제 호출 횟수 출력
-> 쿼리에서 함수를 100만번 호출했지만 실제 호출 횟수는 50번에 불과한 것을 client_info 값을 보고 알 수 있다.
입력 값이 1부터 50까지, 총 50개뿐이므로 Deterministic으로 선언햇을 때 함수 호출은 50번만 발생했다.
SUM을 구하는 쿼리이므로 한 번의 Fetch Call 내에 캐시 상태를 유지하며 처리를 완료하였다.
--DETERMINISTIC제거 후 수행
->8.8초 수행.
--client_info 확인
-- 함수 안에 쿼리 문장을 포함하고 있다면 그 함수는 일관성이 보장되지 않는다. 즉, 같은 입력 값에 대해 언제라도 다른 출력 값을
낼 수 있다는 뜻이다.
- Deterministic 키워드는 그 함수가 일관성 있는 결과를 리턴함을 선언하는 것일 뿐, 그것을 넣었다고 해서 일관성이 보장되는 것은
아니다. 단지, 함수를 구현한 개발자가 그 함수의 일관성 있는 결과 출력을 책임진다는 선언적 의미만을 갖는다.
--오라클은 Deterministic 함수일 때만 캐싱 기능이 작동하도록 구현되있다.
- 결론적으로 앞에서 본 ACCUM 함수는 시점과 무관하게 항상 일관성 있는 결과를 출력하므로 캐싱효과를 위한 Deterministic 함수
의 올바른 활용 사례지만, 함수가 쿼리문을 포함할 때는 캐싱효과를 위해 함부로 Deterministic으로 선언해선 안 된다. 만약 select
문을 포함한 함수를 Deterministic으로 선언하면 일관성 측면에서 뜻하지 않은 결과를 초래할 수 있으므로 주의해야 한다.
(6) 복잡한 함수 로직을 풀어 SQL로 구현
※수정 주가 : 현재와 과거 주가를 비교할 때는 수정주가를 자주 사용한다. 즉, 조회 시점 기준으로 과거 주가를 수정하는 것이다.
수정 주가는 거래일 이후에 발생했던 주가 수정비율을 모두 곱해서 구한다.
-> 12월 14일 수정주가는 24일의 수정비율 0.5(10000/20000)와 27일의 수정비율 0.1(1000/10000)을 곱한 0.05를 적용해서 구한다.
(18000*0.05 = 900)
-> 앞 표에서 12월 27일에 발생한 수정 비율은 수정 주가에 반영되지 않아야 하므로 미리 구해 둔 수정주가는 의미 없게 된다.
< 주가에 영향을 미치는 이벤트가 발생할 때마다 기준가 변경이력을 관리하는 테이블과 사례 데이터 >
<위 테이블을 이용해 수정주가를 구하는 함수>
- 거래일자 다음날부터 조회일자 이전까지의 수정비율을 모두 곱하는 로직이다. 즉, 누적곱을 구하고자 하는 것이다.
< 위 함수를 이용해 수정주가를 확인하는 쿼리 >
-> 쿼리에 사용된 일별종목주가는 [종목코드+거래일자]를 PK로 갖는 테이블로서, 일자별로 모든 종목에 대한 당일종가가 구해져
있다. 그림 5-13에서 표현한 선분이력처럼 각 일자구간(시작일~종료일)별로 누적수정비율을 갖는 중간집합을 생성할 수만
있다면 일별종목주가 테이블과의 조인을 통해 의외로 쉽게 문제를 풀 수 있다.
최종 튜닝 결과 : EXP(지수)와 LN(자연로그) 함수를 포함하는 인라인 뷰가 5-13과 같은 형태의 중간집합을 가공해 내는 부분이다.
즉, 누적곱을 구하는 쿼리다. 나머지는 이 중간집합과 일별종목주가 테이블을 조인하는 것에 불과하다.
-> 실시간 쿼리의 함수 호출 부하를 해소했음은 물론, 수정주가를 미리 구해 저장해 두려고 매일 과거 10년치 데이터를 갱신하는
배치 프로그램을 더는 수행할 필요가 없게 된다.
-> 함수호출에 의한 부하가 얼마나 심각한 것인지, 그리고 이를 풀어냄으로써 얼마만큼 획기적인 성능개선을 이룰 수 있는지 이해.