테이블 반환 매개 변수(TVP)
여러 행을 한 번에 넘깁니다. 반복 호출을 한 번으로 줄이는 방법이고, 4부를 여기서 맺습니다.
여러 행을 한 번에 넘깁니다
댓글 100건을 한꺼번에 넣어야 한다고 합시다. 4.3 까지의 방법으로는 프로시저를 100번 부르는 수밖에 없습니다. 매개 변수는 값 하나씩만 받기 때문입니다.
테이블 반환 매개 변수는 표 하나를 통째로 넘깁니다. 먼저 모양을 형식으로 만들어 두고, 그 형식을 매개 변수로 받습니다.
-- (1) 넘길 표의 모양을 형식으로 만듭니다. CREATE TYPE Board.CommentList AS TABLE ( post_num int NOT NULL, user_num int NOT NULL, content nvarchar(1000) NOT NULL, PRIMARY KEY (post_num, user_num) -- 키도 달 수 있습니다 ); GO -- (2) 그 형식을 매개 변수로 받습니다. READONLY 를 반드시 붙입니다. CREATE OR ALTER PROCEDURE Board.P_COMMENT_ADD_MANY @items Board.CommentList READONLY AS BEGIN SET NOCOUNT ON; INSERT INTO Board.COMMENTS (post_num, user_num, content, reg_date) SELECT post_num, user_num, content, '2026-03-01' FROM @items; SELECT @@ROWCOUNT AS 넣은행; END GO -- (3) 변수에 담아 넘깁니다. DECLARE @t Board.CommentList; INSERT INTO @t (post_num, user_num, content) SELECT TOP (5) 1, num, N'댓글 ' + CAST(num AS nvarchar(10)) FROM Member.USERS ORDER BY num; EXEC Board.P_COMMENT_ADD_MANY @items = @t;
| 넣은행 |
|---|
| 5 |
프로시저 안에서는 그냥 표처럼 다룹니다. 조인해도 되고 집계해도 됩니다. 4.8 에서 배운 집합 사고를 그대로 적용할 수 있습니다.
1,000번이 1번이 됩니다
1,000행을 넣는 일을 두 가지로 해 보고 프로시저가 몇 번 실행되었는지 봅니다.
-- 견주는 데 쓸 표·형식·프로시저를 먼저 만듭니다. CREATE TABLE Board.T_TVP ( num int IDENTITY PRIMARY KEY, code varchar(10) NOT NULL, val int NOT NULL ); GO CREATE TYPE Board.ItemList AS TABLE ( code varchar(10) NOT NULL PRIMARY KEY, -- 키를 달아 둡니다 val int NOT NULL ); GO CREATE OR ALTER PROCEDURE Board.P_ITEM_ADD_ONE @code varchar(10), @val int AS BEGIN SET NOCOUNT ON; INSERT INTO Board.T_TVP (code, val) VALUES (@code, @val); END GO CREATE OR ALTER PROCEDURE Board.P_ITEM_ADD_MANY @items Board.ItemList READONLY AS BEGIN SET NOCOUNT ON; INSERT INTO Board.T_TVP (code, val) SELECT code, val FROM @items; END GO -- (가) 한 건씩 1,000번 부릅니다. DECLARE @i int = 1; WHILE @i <= 1000 BEGIN EXEC Board.P_ITEM_ADD_ONE @code = 'C', @val = @i; SET @i += 1; END -- (나) TVP 로 한 번 부릅니다. DECLARE @t Board.ItemList; INSERT INTO @t (code, val) SELECT TOP (1000) 'C' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS varchar(6)), ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) FROM Board.POSTS; EXEC Board.P_ITEM_ADD_MANY @items = @t; GO -- 프로시저가 몇 번 실행되었는지 봅니다. SELECT OBJECT_NAME(ps.object_id) AS 프로시저, ps.execution_count AS 실행횟수, ps.total_logical_reads AS 논리적읽기, ps.total_worker_time / 1000 AS CPU밀리초 FROM sys.dm_exec_procedure_stats ps WHERE ps.database_id = DB_ID();
| 프로시저 | 실행횟수 | 논리적 읽기 | CPU 밀리초 |
|---|---|---|---|
| P_ITEM_ADD_ONE | 1,000 | 2,020 | 25 |
| P_ITEM_ADD_MANY | 1 | 2,028 | 1 |
이득은 읽기가 아니라 호출 횟수와 CPU 에 있습니다. 부를 때마다 매개 변수를 받고 계획을 찾고 결과를 돌려주는 비용이 붙는데, 그것이 1,000번 일어나느냐 한 번 일어나느냐입니다.
왕복이 끼면 더 벌어집니다
위 실측은 서버 안에서 잰 것입니다. 응용 프로그램이 부를
때는 호출마다 네트워크를 건너갑니다.
sqlcmd 로 배치를 1,000번 보내 확인했습니다.
| 방식 | 밀리초 |
|---|---|
| 배치를 1,000번 보냄 | 210 |
| TVP 로 한 번 보냄 | 12 |
같은 장비의 로컬 연결에서 잰 값입니다. 실행 파일이 뜨는 시간은 뺐습니다.
같은 장비에서도 17배입니다. 웹 서버와 데이터베이스 서버가 나뉘어 있으면 왕복 하나에 밀리초 단위가 붙으므로 차이가 더 커집니다.
서버 안에서만 재면 TVP 가 오히려 느릴 수도 있습니다. 표
변수를 채우는 비용이 있기 때문입니다. TVP 를 쓰는 까닭은 서버 안의 속도가
아니라 왕복을 줄이는 것이라는 점을 기억하십시오. 서버 안에서
1,000행을 만들어 넣는 일이라면 애초에 INSERT … SELECT
한 문장이면 됩니다(4.8).
읽기만 할 수 있고, 바꾸기 어렵습니다
-- (가) READONLY 를 빼면 CREATE OR ALTER PROCEDURE Board.P_BAD (@items Board.CommentList) AS BEGIN SELECT COUNT(*) FROM @items; END GO -- (나) 넘어온 자료를 고치려 하면 CREATE OR ALTER PROCEDURE Board.P_BAD2 (@items Board.CommentList READONLY) AS BEGIN UPDATE @items SET content = N'x'; END
형식을 바꾸기가 까다롭습니다
-- 열을 하나 더하고 싶습니다. ALTER TYPE Board.ItemList ADD note nvarchar(50); -- 그런 문법이 없습니다 -- 그러면 지우고 다시 만들어야 하는데 DROP TYPE Board.ItemList;
열 하나를 더하려면 그 형식을 쓰는 프로시저를 모두 지우고, 형식을 지우고, 형식을 다시 만들고, 프로시저를 다시 만들어야 합니다. 배포 중에 그 사이가 벌어지면 응용 프로그램이 실패합니다.
그래서 형식은 처음에 넉넉히 잡는 편이 낫습니다. 나중에 쓸
수도 있는 열을 NULL 허용으로 미리 두는 것이,
배포 때마다 프로시저를 지웠다 만드는 것보다 낫습니다. 자주 바뀌는 모양이라면
다음 절의 JSON 이 나은 선택입니다.
키를 달아 두면 중복이 막힙니다
DECLARE @t Board.ItemList; -- code 가 기본 키입니다 INSERT INTO @t (code, val) VALUES ('C1', 10); INSERT INTO @t (code, val) VALUES ('C1', 20);
형식에 키를 달아 두면 잘못된 자료가 프로시저까지 오지 않습니다. 안쪽에서 중복을 확인하는 코드를 적지 않아도 됩니다.
쉼표 문자열과 JSON
여러 값을 넘기는 방법이 TVP 만은 아닙니다. 값이 하나짜리
목록이라면 쉼표로 이어 붙여 넘기고
STRING_SPLIT 으로 풀 수 있습니다.
DECLARE @csv nvarchar(max) = N'10,20,30,40,50'; SELECT value AS 값, CAST(value AS int) * 2 AS 두배 FROM STRING_SPLIT(@csv, N','); -- 순서가 필요하면 세 번째 인수를 1 로 줍니다. SELECT value, ordinal FROM STRING_SPLIT(N'10,20,30', N',', 1);
| 값 | 두배 |
|---|---|
| 10 | 20 |
| 20 | 40 |
| 30 | 60 |
| value | ordinal |
|---|---|
| 10 | 1 |
| 20 | 2 |
| 30 | 3 |
열이 여럿이면 문자열로는 금세 지저분해집니다.
-- 쉼표 안에 콜론을 또 넣어야 합니다. DECLARE @csv nvarchar(max) = N'C1:10,C2:20'; SELECT LEFT(value, CHARINDEX(':', value) - 1) AS code, SUBSTRING(value, CHARINDEX(':', value) + 1, 100) AS val FROM STRING_SPLIT(@csv, N','); -- JSON 이면 모양이 그대로 드러납니다. DECLARE @json nvarchar(max) = N'[{"code":"C1","val":10},{"code":"C2","val":20}]'; SELECT code, val FROM OPENJSON(@json) WITH (code varchar(20) '$.code', val int '$.val');
| code | val |
|---|---|
| C1 | 10 |
| C2 | 20 |
무엇을 고릅니까
| 방법 | 어울리는 자리 | 값 |
|---|---|---|
| TVP | 열이 여럿, 자주 부름, 모양이 안정적 | 형식을 바꾸기 어렵습니다 |
| JSON | 열이 여럿, 모양이 자주 바뀜 | 파싱 비용이 붙습니다 |
STRING_SPLIT | 값 하나짜리 목록(번호 목록) | 자료형 검사가 없습니다 |
어느 쪽이든 값을 문장에 이어 붙이지는 마십시오. 4.9 의 주입은
"번호 목록을 IN 절에 이어 붙이는" 자리에서 특히
자주 일어납니다. STRING_SPLIT 에 넘기면 값은
값으로만 다뤄집니다.
넘기는 쪽에서는
ADO.NET 에서는 DataTable 이나
IEnumerable<SqlDataRecord> 를 매개 변수에
실어 보냅니다. 매개 변수 하나에 표가 담깁니다.
var table = new DataTable(); table.Columns.Add("post_num", typeof(int)); table.Columns.Add("user_num", typeof(int)); table.Columns.Add("content", typeof(string)); foreach (var c in comments) table.Rows.Add(c.PostNum, c.UserNum, c.Content); var p = cmd.Parameters.AddWithValue("@items", table); p.SqlDbType = SqlDbType.Structured; p.TypeName = "Board.CommentList"; // 형식 이름을 알려 줍니다
열 순서로 맞춰지는 것이 함정입니다. 형식에 열을 하나 끼워
넣으면 응용 프로그램 쪽 DataTable 도 같은 자리에
넣어야 합니다. 앞 절에서 "형식은 처음에 넉넉히" 라고 한 까닭이 여기에도
있습니다.
이 사이트를 만든 프레임워크도 같은 방식으로 TVP 를 다룹니다. 여러 건을 한 번에 저장하는 화면이 있다면 그 뒤에는 대개 이 매개 변수가 있습니다.
직접 해보기
화면에서 글 여러 개를 골라 조회수를 한 번에 올리려 합니다. 번호 목록을 받아 한 문장으로 처리하는 프로시저를 만들어 보세요. 번호만 넘기므로 형식을 새로 만들 필요는 없습니다.
댓글 여러 건을 TVP 로 받습니다. 없는 글이나 없는 회원이 섞여 있으면 그 건만 빼고 넣고, 몇 건을 걸렀는지 알려 주십시오. 4부에서 배운 것을 함께 적용합니다.
이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_COMMENT_ADD_MANY, Board.P_COMMENT_ADD_SAFE,
Board.P_ITEM_ADD_ONE, Board.P_ITEM_ADD_MANY, Board.P_HIT_BUMP;
DROP TYPE Board.CommentList; DROP TYPE Board.ItemList;
DROP TABLE Board.T_TVP;
절차를 적되, 되도록 적지 않습니다
4부는 SQL 에 절차를 적는 문법으로 시작했습니다. 그런데 4.1 의 마지막 절부터 4.8 까지, 되풀이해서 나온 이야기는 "한 문장으로 되는 일을 절차로 적지 마십시오" 였습니다.
| 단원 | 절차로 적으면 | 한 문장으로 |
|---|---|---|
| 4.1 | 조회 507번 | 조회 1번 |
| 4.4 | 인라인 안 되는 함수 311밀리초 | 8밀리초 |
| 4.7 | 트리거가 행마다 도는 줄 알면 기록 1건 | 집합으로 35건 |
| 4.8 | 커서 4,800밀리초 | 윈도 함수 65밀리초 |
| 4.10 | 프로시저 호출 1,000번 | TVP 로 1번 |
그러면 절차는 무엇에 씁니까. 4부의 나머지 절반이 그 대답입니다 — 프로시저로 이름을 붙이고(4.2), 매개 변수로 값을 받고(4.3), 오류를 알리고(4.5), 트랜잭션으로 묶는(4.6) 일입니다. 이것들은 한 문장으로 대신할 수 없습니다.
절차는 일의 순서를 정하는 데 쓰고, 자료를 다루는 일은 문장에 맡깁니다. 4.10 의 연습 2 가 그 모양입니다 — 트랜잭션과 오류 처리는 절차로 감싸고, 거르고 넣는 일 자체는 한 문장입니다.
5부에서는 그 문장들이 왜 느린지 읽는 법을 봅니다. 3.5 에서 센 페이지와 4부에서 만든 프로시저가 거기서 만납니다.
- TVP 는 표 하나를 매개 변수로 넘깁니다. 먼저
CREATE TYPE … AS TABLE로 모양을 만들고READONLY로 받습니다. - 같은 1,000행을 넣는데 프로시저 호출이 1,000번과 1번입니다(CPU 25밀리초와 1밀리초). 읽은 페이지는 비슷합니다.
- 이득은 왕복을 줄이는 데 있습니다. 서버 안에서만 재면 차이가 작거나 TVP 가 느릴 수도 있습니다. 로컬 연결에서도 210밀리초와 12밀리초였습니다.
READONLY를 빼면 352, 안쪽 자료를 고치려 하면 10700 입니다.- 형식을 바꾸기 어렵습니다.
ALTER TYPE이 없고, 참조하는 프로시저가 있으면 지울 수도 없습니다(3732). 처음에 넉넉히 잡으십시오. - 형식에 키를 달아 두면 잘못된 자료가 프로시저까지 오지 않습니다(2627).
- 값 하나짜리 목록은
STRING_SPLIT, 모양이 자주 바뀌면OPENJSON이 낫습니다. 어느 쪽이든 값을 문장에 이어 붙이지는 마십시오(4.9). - ADO.NET 에서는
SqlDbType.Structured와TypeName으로 넘깁니다. 열은 이름이 아니라 순서로 맞춰집니다.