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

테이블 반환 매개 변수(TVP)

여러 행을 한 번에 넘깁니다. 반복 호출을 한 번으로 줄이는 방법이고, 4부를 여기서 맺습니다.

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

여러 행을 한 번에 넘깁니다

댓글 100건을 한꺼번에 넣어야 한다고 합시다. 4.3 까지의 방법으로는 프로시저를 100번 부르는 수밖에 없습니다. 매개 변수는 값 하나씩만 받기 때문입니다.

테이블 반환 매개 변수는 표 하나를 통째로 넘깁니다. 먼저 모양을 형식으로 만들어 두고, 그 형식을 매개 변수로 받습니다.

SQL
-- (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
한 번 불러 다섯 건이 들어갔습니다. 안쪽은 INSERT … SELECT 한 문장입니다.

프로시저 안에서는 그냥 표처럼 다룹니다. 조인해도 되고 집계해도 됩니다. 4.8 에서 배운 집합 사고를 그대로 적용할 수 있습니다.

이득

1,000번이 1번이 됩니다

1,000행을 넣는 일을 두 가지로 해 보고 프로시저가 몇 번 실행되었는지 봅니다.

SQL
-- 견주는 데 쓸 표·형식·프로시저를 먼저 만듭니다.
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();
결과 — 같은 1,000행
프로시저실행횟수논리적 읽기CPU 밀리초
P_ITEM_ADD_ONE1,0002,02025
P_ITEM_ADD_MANY12,0281
읽은 페이지는 비슷합니다. 결국 같은 행을 쓰기 때문입니다.

이득은 읽기가 아니라 호출 횟수와 CPU 에 있습니다. 부를 때마다 매개 변수를 받고 계획을 찾고 결과를 돌려주는 비용이 붙는데, 그것이 1,000번 일어나느냐 한 번 일어나느냐입니다.

왕복이 끼면 더 벌어집니다

위 실측은 서버 안에서 잰 것입니다. 응용 프로그램이 부를 때는 호출마다 네트워크를 건너갑니다. sqlcmd 로 배치를 1,000번 보내 확인했습니다.

방식밀리초
배치를 1,000번 보냄210
TVP 로 한 번 보냄12

같은 장비의 로컬 연결에서 잰 값입니다. 실행 파일이 뜨는 시간은 뺐습니다.

같은 장비에서도 17배입니다. 웹 서버와 데이터베이스 서버가 나뉘어 있으면 왕복 하나에 밀리초 단위가 붙으므로 차이가 더 커집니다.

서버 안에서만 재면 TVP 가 오히려 느릴 수도 있습니다. 표 변수를 채우는 비용이 있기 때문입니다. TVP 를 쓰는 까닭은 서버 안의 속도가 아니라 왕복을 줄이는 것이라는 점을 기억하십시오. 서버 안에서 1,000행을 만들어 넣는 일이라면 애초에 INSERT … SELECT 한 문장이면 됩니다(4.8).

제약

읽기만 할 수 있고, 바꾸기 어렵습니다

SQL
-- (가) 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
오류
메시지 352 The table-valued parameter "@items" must be declared with the READONLY option. 메시지 10700 The table-valued parameter "@items" is READONLY and cannot be modified.
넘어온 표는 읽기만 합니다. 고칠 것이 있으면 안에서 표 변수로 복사합니다.

형식을 바꾸기가 까다롭습니다

SQL
-- 열을 하나 더하고 싶습니다.
ALTER TYPE Board.ItemList ADD note nvarchar(50);   -- 그런 문법이 없습니다

-- 그러면 지우고 다시 만들어야 하는데
DROP TYPE Board.ItemList;
오류
메시지 102 Incorrect syntax near 'TYPE'. 메시지 3732 Cannot drop type 'Board.ItemList' because it is being referenced by object 'P_ITEM_ADD_MANY'. There may be other objects that reference this type.
ALTER TYPE 이 없고, 참조하는 프로시저가 있으면 지울 수도 없습니다.

열 하나를 더하려면 그 형식을 쓰는 프로시저를 모두 지우고, 형식을 지우고, 형식을 다시 만들고, 프로시저를 다시 만들어야 합니다. 배포 중에 그 사이가 벌어지면 응용 프로그램이 실패합니다.

그래서 형식은 처음에 넉넉히 잡는 편이 낫습니다. 나중에 쓸 수도 있는 열을 NULL 허용으로 미리 두는 것이, 배포 때마다 프로시저를 지웠다 만드는 것보다 낫습니다. 자주 바뀌는 모양이라면 다음 절의 JSON 이 나은 선택입니다.

키를 달아 두면 중복이 막힙니다

SQL
DECLARE @t Board.ItemList;               -- code 가 기본 키입니다
INSERT INTO @t (code, val) VALUES ('C1', 10);
INSERT INTO @t (code, val) VALUES ('C1', 20);
오류
메시지 2627 Violation of PRIMARY KEY constraint … The duplicate key value is (C1).
3.4 에서 본 그 번호입니다. 넘기기 전에 걸러집니다.

형식에 키를 달아 두면 잘못된 자료가 프로시저까지 오지 않습니다. 안쪽에서 중복을 확인하는 코드를 적지 않아도 됩니다.

대안

쉼표 문자열과 JSON

여러 값을 넘기는 방법이 TVP 만은 아닙니다. 값이 하나짜리 목록이라면 쉼표로 이어 붙여 넘기고 STRING_SPLIT 으로 풀 수 있습니다.

SQL
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);
결과
두배
1020
2040
3060
결과 — ordinal 을 붙이면
valueordinal
101
202
303

열이 여럿이면 문자열로는 금세 지저분해집니다.

SQL
-- 쉼표 안에 콜론을 또 넣어야 합니다.
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');
결과 — 둘 다 같습니다
codeval
C110
C220
JSON 은 형식을 미리 만들지 않아도 되고 자료형도 WITH 절에서 정합니다.

무엇을 고릅니까

방법어울리는 자리
TVP열이 여럿, 자주 부름, 모양이 안정적형식을 바꾸기 어렵습니다
JSON열이 여럿, 모양이 자주 바뀜파싱 비용이 붙습니다
STRING_SPLIT값 하나짜리 목록(번호 목록)자료형 검사가 없습니다

어느 쪽이든 값을 문장에 이어 붙이지는 마십시오. 4.9 의 주입은 "번호 목록을 IN 절에 이어 붙이는" 자리에서 특히 자주 일어납니다. STRING_SPLIT 에 넘기면 값은 값으로만 다뤄집니다.

응용 프로그램

넘기는 쪽에서는

ADO.NET 에서는 DataTable 이나 IEnumerable<SqlDataRecord> 를 매개 변수에 실어 보냅니다. 매개 변수 하나에 표가 담깁니다.

C#
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 를 다룹니다. 여러 건을 한 번에 저장하는 화면이 있다면 그 뒤에는 대개 이 매개 변수가 있습니다.

연습

직접 해보기

1. 번호 목록으로 조회수를 올립니다 난이도 하

화면에서 글 여러 개를 골라 조회수를 한 번에 올리려 합니다. 번호 목록을 받아 한 문장으로 처리하는 프로시저를 만들어 보세요. 번호만 넘기므로 형식을 새로 만들 필요는 없습니다.

CREATE OR ALTER PROCEDURE Board.P_HIT_BUMP @nums nvarchar(max) AS BEGIN SET NOCOUNT ON; UPDATE p SET hit_count = hit_count + 1 FROM Board.POSTS p JOIN STRING_SPLIT(@nums, N',') s ON p.num = CAST(s.value AS int); SELECT @@ROWCOUNT AS 올린행; END GO BEGIN TRAN; EXEC Board.P_HIT_BUMP @nums = N'250,251,252'; -- 올린행 3 -- 250 250 → 251 -- 251 257 → 258 -- 252 264 → 265 ROLLBACK; -- 목록을 IN 절에 이어 붙이지 않은 것이 핵심입니다(4.9). -- WHERE num IN (' + @nums + ') ← 주입이 됩니다 -- JOIN STRING_SPLIT(@nums, ',') ← 값은 값으로만 다뤄집니다 -- 없는 번호가 섞여 있어도 조인에서 걸러지므로 따로 확인하지 않아도 됩니다. -- 숫자가 아닌 값이 섞이면 CAST 에서 오류가 납니다. 막으려면 -- TRY_CAST 를 사용하고 NULL 을 걸러내십시오(1.10).
2. 걸러 내고 몇 건을 걸렀는지 알립니다 난이도 중

댓글 여러 건을 TVP 로 받습니다. 없는 글이나 없는 회원이 섞여 있으면 그 건만 빼고 넣고, 몇 건을 걸렀는지 알려 주십시오. 4부에서 배운 것을 함께 적용합니다.

외래 키에 걸리면 문장 전체가 실패합니다(4.5). 걸리기 전에 걸러 내야 합니다. 넣은 행 수는 어디서 읽습니까.
CREATE OR ALTER PROCEDURE Board.P_COMMENT_ADD_SAFE @items Board.CommentList READONLY, @skipped int = 0 OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 4.5 DECLARE @outer bit = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END; -- 4.6 BEGIN TRY IF @outer = 0 BEGIN TRAN; INSERT INTO Board.COMMENTS (post_num, user_num, content, reg_date) SELECT i.post_num, i.user_num, i.content, '2026-03-01' FROM @items i WHERE EXISTS (SELECT 1 FROM Board.POSTS p WHERE p.num = i.post_num) AND EXISTS (SELECT 1 FROM Member.USERS u WHERE u.num = i.user_num); DECLARE @added int = @@ROWCOUNT; -- 4.1 · 바로 다음 문장에서 SELECT @skipped = COUNT(*) - @added FROM @items; IF @outer = 0 COMMIT; SELECT @added AS 넣은행, @skipped AS 거른행; END TRY BEGIN CATCH IF @outer = 0 AND XACT_STATE() <> 0 ROLLBACK; THROW; -- 4.5 END CATCH END GO DECLARE @t Board.CommentList, @sk int; INSERT INTO @t (post_num, user_num, content) VALUES (1, 1, N'정상 댓글'), (2, 2, N'정상 댓글'), (99999, 1, N'없는 글'), (1, 99999, N'없는 회원'); EXEC Board.P_COMMENT_ADD_SAFE @items = @t, @skipped = @sk OUTPUT; -- 넣은행 2 · 거른행 2 -- 4부의 것을 거의 다 사용했습니다. -- 4.1 @@ROWCOUNT 를 바로 다음 문장에서 읽습니다 -- 4.3 OUTPUT 으로 건수를 돌려줍니다 -- 4.5 XACT_ABORT 와 THROW -- 4.6 남의 트랜잭션을 건드리지 않습니다 -- 4.8 걸러 내기를 반복문이 아니라 WHERE EXISTS 로 합니다 -- 4.10 여러 행을 한 번에 받습니다 -- 걸러 내지 않고 그냥 넣으면 외래 키에 걸려(547) 네 건 모두 들어가지 않습니다. -- "일부만 넣고 나머지를 알린다" 는 요구가 있을 때 이 모양이 됩니다.

이 단원에서 만든 것을 지웁니다.
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부를 맺으며

절차를 적되, 되도록 적지 않습니다

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.StructuredTypeName 으로 넘깁니다. 열은 이름이 아니라 순서로 맞춰집니다.