사용자 정의 함수
스칼라 함수와 인라인 TVF, 그리고 스칼라 함수가 느려지는 까닭입니다. 인라인이 되는지가 갈림길입니다.
값을 내는 것과 일을 하는 것
프로시저는 일을 시키는 것입니다. 표를 고치고 결과를 여러 개 낼 수 있습니다. 함수는 값을 내는 것입니다. 문장 안에서 열이나 표가 오는 자리에 놓입니다.
그래서 함수는 표를 고칠 수 없습니다.
SELECT 한 줄 안에서 함수가 507번 불리는데 그때마다
자료가 바뀐다면 결과를 예측할 수 없기 때문입니다.
- 스칼라 함수 — 값 하나를 냅니다. 열이 오는 자리에 놓입니다.
- 인라인 테이블 반환 함수 — 표를 냅니다.
FROM뒤에 놓입니다. 매개 변수를 받는 뷰라고 보면 됩니다. - 다중 문 테이블 반환 함수 — 역시 표를 내지만 만드는 방식이 다르고, 대개 피해야 합니다.
느리다는 말이 반만 맞습니다
글의 깊이를 이름으로 바꾸는 함수를 만들어 봅니다. 같은 일을 두 가지로 적습니다.
-- (가) 한 줄로 CREATE OR ALTER FUNCTION Board.F_LEVEL_SIMPLE (@depth smallint) RETURNS nvarchar(20) AS BEGIN RETURN CASE @depth WHEN 0 THEN N'원글' WHEN 1 THEN N'답글' ELSE N'답답글' END; END GO -- (나) 같은 결과인데 반복문으로 CREATE OR ALTER FUNCTION Board.F_LEVEL_LOOP (@depth smallint) RETURNS nvarchar(20) AS BEGIN DECLARE @r nvarchar(20) = N'답답글', @i int = 0; WHILE @i <= 1 BEGIN IF @i = @depth BEGIN SET @r = CASE @i WHEN 0 THEN N'원글' ELSE N'답글' END; BREAK; END SET @i += 1; END RETURN @r; END GO -- 엔진이 이 함수를 문장에 녹여 넣을 수 있는지 봅니다. SELECT o.name AS 함수, m.is_inlineable AS 인라인가능 FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.type = 'FN' AND o.is_ms_shipped = 0;
| 함수 | 인라인가능 |
|---|---|
| F_LEVEL_SIMPLE | 1 |
| F_LEVEL_LOOP | 0 |
인라인이 된다는 것은 엔진이 함수를 문장 안에 녹여 넣는다는 뜻입니다. 그러면 함수라는 것 자체가 사라지고 직접 적은 것과 같아집니다. 10만 행짜리 표에서 재 봅니다.
-- depth 만 담은 10만 행짜리 표를 만들어 셋을 견줍니다. DECLARE @s datetime2(7), @r nvarchar(20); SET @s = SYSDATETIME(); SELECT @r = CASE depth WHEN 0 THEN N'원글' WHEN 1 THEN N'답글' ELSE N'답답글' END FROM Board.T_F; SELECT DATEDIFF(millisecond, @s, SYSDATETIME()) AS 밀리초; SET @s = SYSDATETIME(); SELECT @r = Board.F_LEVEL_SIMPLE(depth) FROM Board.T_F; SELECT DATEDIFF(millisecond, @s, SYSDATETIME()) AS 밀리초; SET @s = SYSDATETIME(); SELECT @r = Board.F_LEVEL_LOOP(depth) FROM Board.T_F; SELECT DATEDIFF(millisecond, @s, SYSDATETIME()) AS 밀리초;
| 방식 | 밀리초 |
|---|---|
| 인라인으로 직접 적음 | 7~8 |
| 인라인되는 함수 | 7~9 |
| 인라인 안 되는 함수 | 310~312 |
인라인되는 함수는 직접 적은 것과 같습니다. 이름을 붙여 두는 이점만 얻고 값은 치르지 않습니다. 인라인되지 않는 함수는 행마다 따로 실행되고, 10만 행이면 10만 번입니다.
읽은 페이지로는 이 차이가 드러나지 않습니다. 페이지를 더 읽는 것이 아니라 같은 페이지를 읽으면서 행마다 함수를 부르는 비용이기 때문입니다. 4.1 에서 반복문이 비쌌던 것과 같은 구조입니다.
인라인을 막는 것들입니다 — 반복문, 여러 문장에 걸친 변수 대입,
EXEC, 표를 조회하면서 집계하는 식, 시간 종속 함수.
만들고 나서 is_inlineable 을 확인하는 습관을
들이십시오. SQL Server 2019 이전이거나 호환성 수준이 140 이하면
모든 스칼라 함수가 인라인되지 않습니다.
인라인과 다중 문은 다른 물건입니다
이름이 비슷하고 부르는 방법도 같지만 동작이 완전히 다릅니다. 게시판 글을 내는 함수를 두 가지로 적어 봅니다.
-- (가) 인라인: RETURN 뒤에 SELECT 하나뿐입니다. BEGIN 도 없습니다. CREATE OR ALTER FUNCTION Board.FT_POSTS_INLINE (@board_num int) RETURNS TABLE AS RETURN ( SELECT num, title, group_num, sort_no, hit_count FROM Board.POSTS WHERE board_num = @board_num ); GO -- (나) 다중 문: 표 변수를 선언하고 채워서 돌려줍니다. CREATE OR ALTER FUNCTION Board.FT_POSTS_MULTI (@board_num int) RETURNS @t TABLE (num int, title nvarchar(200), group_num int, sort_no int, hit_count int) AS BEGIN INSERT INTO @t SELECT num, title, group_num, sort_no, hit_count FROM Board.POSTS WHERE board_num = @board_num; RETURN; END GO -- 같은 방법으로 부릅니다. SELECT TOP (20) num, title FROM Board.FT_POSTS_INLINE(2) ORDER BY group_num DESC, sort_no; SELECT TOP (20) num, title FROM Board.FT_POSTS_MULTI(2) ORDER BY group_num DESC, sort_no;
인라인 함수는 계획에서 사라졌습니다. 뷰(3.7)와 똑같이 바깥
문장에 펼쳐져 TOP 20 과
ORDER BY 까지 함께 최적화되었습니다.
IX_POSTS_list 로 20건만 읽고 정렬도 없습니다.
다중 문 함수는 먼저 다 실행됩니다.
Sequence 가 함수를 돌려 315건을 표 변수에 채운 뒤,
그 표를 Table Scan 으로 읽고
Sort 로 정렬하고서야 20건을 끊습니다.
바깥 조건이 안쪽으로 들어가지 못합니다.
-- 같은 조회를 100번씩 합니다.
| 방식 | 밀리초 |
|---|---|
| 인라인 TVF | 3~4 |
| 다중 문 TVF | 59~69 |
테이블 반환 함수는 인라인으로 적으십시오.
BEGIN 이 필요하다고 느껴지면 그것은 프로시저로
만들 일입니다. 다중 문 함수가 정말 필요한 경우는 재귀 같은 절차가 결과를
만드는 자리뿐이고, 그때도 임시 표를 채우는 프로시저가 대개 낫습니다.
함수가 할 수 없는 일
함수는 바깥에 자국을 남기면 안 됩니다. 문장 하나 안에서 몇 번 불릴지 정해져 있지 않기 때문입니다.
-- 표를 고치려 하면 CREATE OR ALTER FUNCTION Board.F_BAD1 (@num int) RETURNS int AS BEGIN UPDATE Board.POSTS SET hit_count = hit_count + 1 WHERE num = @num; RETURN 1; END GO -- 오류를 던지려 하면 CREATE OR ALTER FUNCTION Board.F_BAD4 (@n int) RETURNS int AS BEGIN IF @n < 0 THROW 50001, N'음수는 안 됩니다', 1; RETURN @n; END
함수 안에서는 실패를 알릴 방법이 마땅치 않습니다.
THROW 도 RAISERROR 도
막히므로, 잘못된 입력에는 NULL 을 내거나 0 으로 나누어
일부러 오류를 내는 우회가 쓰입니다. 검사가 필요한 일은 프로시저로
만드십시오.
EXEC 는 조금 다릅니다.
만들 때는 통과하고 부를 때 터집니다 — 4.2 에서 본 지연 이름
확인과 같은 모양입니다.
GETDATE() 는 쓸 수 있습니다.
예전 판에서는 막혔지만 지금은 됩니다. 다만 그런 함수는 결정적이지 않아
계산 열이나 인덱스에 사용할 수 없습니다(3.6). 그리고 인라인
대상에서도 빠집니다.
결정적이라고 알려 주어야 합니다
3.6 에서 계산 열에 인덱스를 걸려면 식이 결정적이어야 한다고 했습니다. 그런데 방금 만든 단순한 함수조차 결정적으로 인정받지 못합니다.
-- 같은 함수에 WITH SCHEMABINDING 만 더합니다. CREATE OR ALTER FUNCTION Board.F_LEVEL_SB (@depth smallint) RETURNS nvarchar(20) WITH SCHEMABINDING AS BEGIN RETURN CASE @depth WHEN 0 THEN N'원글' WHEN 1 THEN N'답글' ELSE N'답답글' END; END GO SELECT o.name AS 함수, m.is_inlineable AS 인라인가능, OBJECTPROPERTY(o.object_id, 'IsDeterministic') AS 결정적, OBJECTPROPERTY(o.object_id, 'IsPrecise') AS 정확 FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.name IN ('F_LEVEL_SIMPLE', 'F_LEVEL_SB');
| 함수 | 인라인가능 | 결정적 | 정확 |
|---|---|---|---|
| F_LEVEL_SIMPLE | 1 | 0 | 0 |
| F_LEVEL_SB | 1 | 1 | 1 |
WITH SCHEMABINDING 이 없으면 엔진은
함수가 참조하는 것이 나중에 바뀔 수 있다고 보고 결정적이라고
단정하지 않습니다. 붙이면 참조하는 표와 열이 잠기고(3.7), 그 대가로
결정적이라는 판정을 받습니다.
-- 결정적이므로 계산 열에 사용하고 인덱스까지 걸 수 있습니다(3.6). ALTER TABLE Board.POSTS ADD level_name AS Board.F_LEVEL_SB(depth); CREATE NONCLUSTERED INDEX IX_POSTS_level ON Board.POSTS (level_name); SELECT TOP (3) num, depth, level_name FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
| num | depth | level_name |
|---|---|---|
| 6 | 0 | 원글 |
| 352 | 1 | 답글 |
| 456 | 2 | 답답글 |
스칼라 함수에는 WITH SCHEMABINDING 을
붙이는 것을 기본으로 삼으십시오. 얻는 것이 둘입니다 — 결정적 판정과,
참조하는 열이 사라지는 것을 막는 보호입니다. 잃는 것은 그 열을 고칠 때 함수를
먼저 손봐야 한다는 것뿐입니다.
직접 해보기
3.10 의 연습에서 계산 열로 만들었던 것을 함수로 만들어 보세요. 바이트를 받아 KB 를 냅니다. 결정적으로 판정받게 하고 인라인이 되는지 확인하십시오.
아래 함수는 목록 화면에서 답글을 들여쓰는 데 사용합니다.
is_inlineable 이 0 입니다.
무엇이 막고 있는지 찾고 고쳐 보세요.
CREATE OR ALTER FUNCTION Board.F_INDENT (@depth smallint) RETURNS nvarchar(40) WITH SCHEMABINDING AS BEGIN DECLARE @r nvarchar(40) = N'', @i smallint = 0; WHILE @i < @depth BEGIN SET @r = @r + N' '; SET @i += 1; END RETURN @r; END
이 단원에서 만든 것을 지웁니다.
DROP FUNCTION Board.F_LEVEL_SIMPLE, Board.F_LEVEL_LOOP, Board.F_LEVEL_SB,
Board.F_BAD2, Board.F_BAD3, Board.F_SIZE_KB, Board.F_INDENT, Board.F_INDENT2,
Board.FT_POSTS_INLINE, Board.FT_POSTS_MULTI;
DROP TABLE Board.T_F;
- 함수는 값을 내는 것이라 표를 고칠 수 없습니다.
UPDATE도THROW도 443 으로 막힙니다. - 스칼라 함수는 인라인되면 직접 적은 것과 같고 안 되면 행마다 실행됩니다(10만 행에서 7~9밀리초와 310밀리초 대).
sys.sql_modules.is_inlineable로 확인하십시오. 반복문·여러 문장에 걸친 대입·EXEC·시간 종속 함수가 인라인을 막습니다.- 인라인 테이블 반환 함수는 계획에서 사라집니다. 매개 변수가 있는 뷰이고, 바깥 조건과 함께 최적화됩니다.
- 다중 문 테이블 반환 함수는 먼저 다 채운 뒤 넘깁니다. 계획에
Sequence와Table Scan이 붙고, 바깥 조건이 안으로 들어가지 못합니다(100회에 3~4밀리초와 59~69밀리초). BEGIN이 필요한 테이블 반환 함수는 프로시저로 만들 일입니다.- 스칼라 함수에는
WITH SCHEMABINDING을 붙이십시오. 없으면 결정적으로 판정받지 못해 계산 열과 인덱스에 사용할 수 없습니다(3.6).