MSSQL 4.4 · 4부. T-SQL 프로그래밍

사용자 정의 함수

스칼라 함수와 인라인 TVF, 그리고 스칼라 함수가 느려지는 까닭입니다. 인라인이 되는지가 갈림길입니다.

예상 학습 시간 20분 난이도 중급
개념 설명

값을 내는 것과 일을 하는 것

프로시저는 일을 시키는 것입니다. 표를 고치고 결과를 여러 개 낼 수 있습니다. 함수는 값을 내는 것입니다. 문장 안에서 열이나 표가 오는 자리에 놓입니다.

그래서 함수는 표를 고칠 수 없습니다. SELECT 한 줄 안에서 함수가 507번 불리는데 그때마다 자료가 바뀐다면 결과를 예측할 수 없기 때문입니다.

  • 스칼라 함수 — 값 하나를 냅니다. 열이 오는 자리에 놓입니다.
  • 인라인 테이블 반환 함수 — 표를 냅니다. FROM 뒤에 놓입니다. 매개 변수를 받는 뷰라고 보면 됩니다.
  • 다중 문 테이블 반환 함수 — 역시 표를 내지만 만드는 방식이 다르고, 대개 피해야 합니다.
스칼라 함수

느리다는 말이 반만 맞습니다

글의 깊이를 이름으로 바꾸는 함수를 만들어 봅니다. 같은 일을 두 가지로 적습니다.

SQL
-- (가) 한 줄로
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_SIMPLE1
F_LEVEL_LOOP0
결과는 같은 함수인데 한쪽만 인라인이 됩니다.

인라인이 된다는 것은 엔진이 함수를 문장 안에 녹여 넣는다는 뜻입니다. 그러면 함수라는 것 자체가 사라지고 직접 적은 것과 같아집니다. 10만 행짜리 표에서 재 봅니다.

SQL
-- 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 밀리초;
결과 — 10만 행
방식밀리초
인라인으로 직접 적음7~8
인라인되는 함수7~9
인라인 안 되는 함수310~312
세 번씩 돌려 얻은 범위입니다. 시간은 환경마다 다르므로 40배라는 배율을 보십시오.

인라인되는 함수는 직접 적은 것과 같습니다. 이름을 붙여 두는 이점만 얻고 값은 치르지 않습니다. 인라인되지 않는 함수는 행마다 따로 실행되고, 10만 행이면 10만 번입니다.

읽은 페이지로는 이 차이가 드러나지 않습니다. 페이지를 더 읽는 것이 아니라 같은 페이지를 읽으면서 행마다 함수를 부르는 비용이기 때문입니다. 4.1 에서 반복문이 비쌌던 것과 같은 구조입니다.

인라인을 막는 것들입니다 — 반복문, 여러 문장에 걸친 변수 대입, EXEC, 표를 조회하면서 집계하는 식, 시간 종속 함수. 만들고 나서 is_inlineable 을 확인하는 습관을 들이십시오. SQL Server 2019 이전이거나 호환성 수준이 140 이하면 모든 스칼라 함수가 인라인되지 않습니다.

테이블 반환 함수

인라인과 다중 문은 다른 물건입니다

이름이 비슷하고 부르는 방법도 같지만 동작이 완전히 다릅니다. 게시판 글을 내는 함수를 두 가지로 적어 봅니다.

SQL
-- (가) 인라인: 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;
계획 — 인라인
|--Top(TOP EXPRESSION:((20))) |--Index Seek(OBJECT:([POSTS].[IX_POSTS_list]), SEEK:([POSTS].[board_num]=(2)) ORDERED FORWARD)
계획 — 다중 문
|--Sequence |--Table-valued function(OBJECT:([Board].[FT_POSTS_MULTI])) |--Sort(TOP 20, ORDER BY:([FT_POSTS_MULTI].[group_num] DESC, …)) |--Table Scan(OBJECT:([Board].[FT_POSTS_MULTI]))
위쪽에는 함수가 아예 없습니다. 아래쪽에는 함수와 Sort 와 Table Scan 이 있습니다.

인라인 함수는 계획에서 사라졌습니다. 뷰(3.7)와 똑같이 바깥 문장에 펼쳐져 TOP 20ORDER BY 까지 함께 최적화되었습니다. IX_POSTS_list 로 20건만 읽고 정렬도 없습니다.

다중 문 함수는 먼저 다 실행됩니다. Sequence 가 함수를 돌려 315건을 표 변수에 채운 뒤, 그 표를 Table Scan 으로 읽고 Sort 로 정렬하고서야 20건을 끊습니다. 바깥 조건이 안쪽으로 들어가지 못합니다.

SQL
-- 같은 조회를 100번씩 합니다.
결과 — 100회
방식밀리초
인라인 TVF3~4
다중 문 TVF59~69
507행짜리 표에서 스무 배쯤입니다. 표가 커지면 그대로 벌어집니다.

테이블 반환 함수는 인라인으로 적으십시오. BEGIN 이 필요하다고 느껴지면 그것은 프로시저로 만들 일입니다. 다중 문 함수가 정말 필요한 경우는 재귀 같은 절차가 결과를 만드는 자리뿐이고, 그때도 임시 표를 채우는 프로시저가 대개 낫습니다.

제약

함수가 할 수 없는 일

함수는 바깥에 자국을 남기면 안 됩니다. 문장 하나 안에서 몇 번 불릴지 정해져 있지 않기 때문입니다.

SQL
-- 표를 고치려 하면
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
오류 — 둘 다
메시지 443 Invalid use of a side-effecting operator 'UPDATE' within a function. 메시지 443 Invalid use of a side-effecting operator 'THROW' within a function.
부작용이 있는 문장은 만들 때 막힙니다.

함수 안에서는 실패를 알릴 방법이 마땅치 않습니다. THROWRAISERROR 도 막히므로, 잘못된 입력에는 NULL 을 내거나 0 으로 나누어 일부러 오류를 내는 우회가 쓰입니다. 검사가 필요한 일은 프로시저로 만드십시오.

EXEC 는 조금 다릅니다. 만들 때는 통과하고 부를 때 터집니다 — 4.2 에서 본 지연 이름 확인과 같은 모양입니다.

GETDATE() 는 쓸 수 있습니다. 예전 판에서는 막혔지만 지금은 됩니다. 다만 그런 함수는 결정적이지 않아 계산 열이나 인덱스에 사용할 수 없습니다(3.6). 그리고 인라인 대상에서도 빠집니다.

SCHEMABINDING

결정적이라고 알려 주어야 합니다

3.6 에서 계산 열에 인덱스를 걸려면 식이 결정적이어야 한다고 했습니다. 그런데 방금 만든 단순한 함수조차 결정적으로 인정받지 못합니다.

SQL
-- 같은 함수에 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_SIMPLE100
F_LEVEL_SB111
내용이 같은 함수인데 SCHEMABINDING 하나로 판정이 바뀝니다.

WITH SCHEMABINDING 이 없으면 엔진은 함수가 참조하는 것이 나중에 바뀔 수 있다고 보고 결정적이라고 단정하지 않습니다. 붙이면 참조하는 표와 열이 잠기고(3.7), 그 대가로 결정적이라는 판정을 받습니다.

SQL
-- 결정적이므로 계산 열에 사용하고 인덱스까지 걸 수 있습니다(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;
결과
numdepthlevel_name
60원글
3521답글
4562답답글
SCHEMABINDING 없이 만든 함수로는 이 인덱스가 걸리지 않습니다.

스칼라 함수에는 WITH SCHEMABINDING 을 붙이는 것을 기본으로 삼으십시오. 얻는 것이 둘입니다 — 결정적 판정과, 참조하는 열이 사라지는 것을 막는 보호입니다. 잃는 것은 그 열을 고칠 때 함수를 먼저 손봐야 한다는 것뿐입니다.

연습

직접 해보기

1. 첨부 크기를 KB 로 난이도 하

3.10 의 연습에서 계산 열로 만들었던 것을 함수로 만들어 보세요. 바이트를 받아 KB 를 냅니다. 결정적으로 판정받게 하고 인라인이 되는지 확인하십시오.

CREATE OR ALTER FUNCTION Board.F_SIZE_KB (@bytes bigint) RETURNS int WITH SCHEMABINDING -- 이것이 있어야 결정적으로 판정됩니다 AS BEGIN RETURN CAST(@bytes / 1024 AS int); END GO SELECT o.name AS 함수, m.is_inlineable AS 인라인가능, OBJECTPROPERTY(o.object_id, 'IsDeterministic') AS 결정적 FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.name = 'F_SIZE_KB'; -- F_SIZE_KB 인라인가능 1 결정적 1 SELECT TOP (3) num, origin_name, size_bytes, Board.F_SIZE_KB(size_bytes) AS KB FROM Board.FILES ORDER BY num; -- 1 첨부_5.pdf 7168 7 -- 2 첨부_10.xlsx 12288 12 -- 3 첨부_15.zip 17408 17 -- 계산 열(3.6)로 두는 것과 함수로 두는 것의 차이입니다. -- 계산 열 그 표에서만 사용합니다. 인덱스를 걸 수 있습니다. -- 함수 어느 표에서나 사용합니다. 규칙이 한곳에 모입니다. -- 둘을 함께 쓸 수도 있습니다 — 계산 열의 식으로 이 함수를 부르면 됩니다.
2. 인라인되지 않는 함수를 고칩니다 난이도 중

아래 함수는 목록 화면에서 답글을 들여쓰는 데 사용합니다. is_inlineable 이 0 입니다. 무엇이 막고 있는지 찾고 고쳐 보세요.

SQL
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
같은 문자열을 여러 번 이어 붙이는 내장 함수가 있습니다. 반복문이 사라지면 어떻게 됩니까.
-- WHILE 이 인라인을 막고 있습니다. REPLICATE 로 한 줄이 됩니다. CREATE OR ALTER FUNCTION Board.F_INDENT2 (@depth smallint) RETURNS nvarchar(40) WITH SCHEMABINDING AS BEGIN RETURN REPLICATE(N' ', @depth); 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.name IN ('F_INDENT', 'F_INDENT2'); -- F_INDENT 0 -- F_INDENT2 1 -- 10만 행에 적용했을 때 -- 반복문 271~346 밀리초 -- REPLICATE 8~9 밀리초 SELECT TOP (3) num, depth, N'[' + Board.F_INDENT2(depth) + N']' AS 들여쓰기 FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no; -- 6 0 [] -- 352 1 [ ] -- 456 2 [ ] -- 반복문을 걷어낼 수 없는 함수라면 애초에 함수로 만들지 마십시오. -- 값을 미리 계산해 열에 담거나(3.6), 부르는 쪽 문장에 녹여 넣는 편이 낫습니다.

이 단원에서 만든 것을 지웁니다.
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;

요약
  • 함수는 값을 내는 것이라 표를 고칠 수 없습니다. UPDATETHROW443 으로 막힙니다.
  • 스칼라 함수는 인라인되면 직접 적은 것과 같고 안 되면 행마다 실행됩니다(10만 행에서 7~9밀리초와 310밀리초 대).
  • sys.sql_modules.is_inlineable 로 확인하십시오. 반복문·여러 문장에 걸친 대입·EXEC·시간 종속 함수가 인라인을 막습니다.
  • 인라인 테이블 반환 함수는 계획에서 사라집니다. 매개 변수가 있는 뷰이고, 바깥 조건과 함께 최적화됩니다.
  • 다중 문 테이블 반환 함수는 먼저 다 채운 뒤 넘깁니다. 계획에 SequenceTable Scan 이 붙고, 바깥 조건이 안으로 들어가지 못합니다(100회에 3~4밀리초와 59~69밀리초).
  • BEGIN 이 필요한 테이블 반환 함수는 프로시저로 만들 일입니다.
  • 스칼라 함수에는 WITH SCHEMABINDING 을 붙이십시오. 없으면 결정적으로 판정받지 못해 계산 열과 인덱스에 사용할 수 없습니다(3.6).