MSSQL LAB
MSSQL 6.5 · 6부. 운영과 실전

응용 프로그램에서 부르기

ADO.NET 으로 프로시저를 호출하고 매개 변수를 넘깁니다.

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

프로시저를 부릅니다

4부에서 만든 프로시저를 응용 프로그램에서 부르는 것이 이 단원입니다. 아래 코드는 실제로 컴파일해 돌린 것입니다.

먼저 프로시저입니다
CREATE OR ALTER PROCEDURE Board.P_POST_LIST_DEMO
    @board_num int,
    @size      int = 20,
    @total     int = 0 OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @total = COUNT(*) FROM Board.POSTS WHERE board_num = @board_num;

    SELECT TOP (@size) num, title, user_num, reg_date, hit_count
    FROM Board.POSTS WHERE board_num = @board_num
    ORDER BY group_num DESC, sort_no;

    RETURN 0;
END
C#
await using var con = new SqlConnection(cs);
await using var cmd = new SqlCommand("Board.P_POST_LIST_DEMO", con);
cmd.CommandType = CommandType.StoredProcedure;

// 형식과 크기를 지정합니다. 뒤에서 까닭을 봅니다.
cmd.Parameters.Add("@board_num", SqlDbType.Int).Value = 2;
cmd.Parameters.Add("@size", SqlDbType.Int).Value = 3;

// OUTPUT 매개 변수
var total = cmd.Parameters.Add("@total", SqlDbType.Int);
total.Direction = ParameterDirection.Output;

// RETURN 값
var ret = cmd.Parameters.Add("@ret", SqlDbType.Int);
ret.Direction = ParameterDirection.ReturnValue;

await con.OpenAsync();
await using var r = await cmd.ExecuteReaderAsync();

// 열 번호를 미리 얻어 두면 행마다 이름을 찾지 않습니다.
int numOrd = r.GetOrdinal("num"), titleOrd = r.GetOrdinal("title");
while (await r.ReadAsync())
    Console.WriteLine($"  {r.GetInt32(numOrd),4}  {r.GetString(titleOrd)}");

// 리더를 닫아야 OUTPUT 과 RETURN 이 채워집니다.
await r.CloseAsync();
Console.WriteLine($"  총건수 = {total.Value}   반환값 = {ret.Value}");
결과
346 저장 프로시저 예제 모음 345 실행 계획 예제 모음 454 [답글] 실행 계획 예제 모음 총건수 = 315 반환값 = 0
315 는 자유 게시판의 전체 글 수입니다. 목록은 세 건만 받았습니다.

리더를 닫기 전에는 OUTPUT 값이 비어 있습니다. 결과 집합을 다 읽어야 서버가 마지막 패킷에 그 값을 실어 보내기 때문입니다. total.Valuenull 로 나온다면 리더를 아직 닫지 않은 것입니다.

실측

AddWithValue 가 계획을 늘립니다

AddWithValue 는 편해 보입니다. 형식을 적지 않아도 되니까요. 그 형식을 값에서 짐작하는 것이 문제입니다.

C#
cmd.Parameters.AddWithValue("@t", "인덱스 질문드립니다");
var p = cmd.Parameters["@t"];
Console.WriteLine($"  형식: {p.SqlDbType}({p.Size})");
결과
형식: NVarChar(10)
글자 수만큼 크기가 잡혔습니다. 검색어가 바뀌면 크기도 바뀝니다.

크기가 다르면 서버가 다른 문장으로 봅니다. 같은 SQL 을 길이가 다른 다섯 문자열로 불러 보았습니다.

C#
string[] words = { "가", "가나", "가나다", "가나다라", "가나다라마" };

foreach (var w in words) {
    await using var cmd = new SqlCommand(
        "SELECT COUNT(*) FROM Board.POSTS WHERE title = @t", con);
    cmd.Parameters.AddWithValue("@t", w);          // 짐작하게 두면
    // cmd.Parameters.Add("@t", SqlDbType.NVarChar, 200).Value = w;   // 정해 주면
    await cmd.ExecuteScalarAsync();
}
결과 — 캐시에 쌓인 계획 수
적는 법5번 부른 뒤 계획 수
AddWithValue5
형식과 길이를 지정1
검색어 길이마다 계획이 따로 만들어집니다. 사람이 넣는 검색어라면 길이가 수십 가지입니다.

계획 캐시가 쓸데없이 커지고 컴파일이 되풀이됩니다. 5.3 에서 본 컴파일 비용이 매개 변수 하나를 어떻게 적었느냐로 늘어납니다. 게다가 5.4 의 암시적 형 변환도 여기서 생깁니다 — 열이 varchar 인데 AddWithValuenvarchar 로 보내면 3장이 598장이 됩니다.

열 형식이렇게 적습니다
nvarchar(200)Add("@p", SqlDbType.NVarChar, 200)
varchar(20)Add("@p", SqlDbType.VarChar, 20)
nvarchar(max)Add("@p", SqlDbType.NVarChar, -1)
datetime2(0)Add("@p", SqlDbType.DateTime2)
decimal(18,2)Add(…)Precision·Scale 지정

길이는 열에 적힌 그대로 두십시오. 값의 길이가 아닙니다. 그래야 어떤 값을 넣어도 같은 계획을 씁니다. max 열은 -1 입니다.

오류

THROW 가 예외로 넘어옵니다

4.5 에서 THROW 로 오류를 알렸습니다. 그것이 응용 프로그램에서 어떻게 보이는지 봅니다.

프로시저 쪽
IF LEN(LTRIM(RTRIM(@title))) = 0
BEGIN
    THROW 50001, N'제목을 입력하십시오.', 1;
END
C#
try {
    await cmd.ExecuteNonQueryAsync();
    Console.WriteLine($"  넣었습니다. num = {num.Value}");
}
catch (SqlException ex) {
    Console.WriteLine($"  Number={ex.Number}  Class={ex.Class}  \"{ex.Message}\"");
}
결과 — 정상 제목과 빈 제목으로 각각
넣었습니다. num = 510 SqlException Number=50001 Class=16 "제목을 입력하십시오."
THROW 에 적은 번호가 SqlException.Number 로 그대로 옵니다.

오류 번호로 갈라 처리할 수 있습니다. 4.5 에서 번호를 정해 두라고 한 값이 여기서 드러납니다.

C#
catch (SqlException ex) when (ex.Number == 50001) {
    // 사람이 고칠 수 있는 것 — 화면에 그대로 보여 줍니다.
    ModelState.AddModelError("", ex.Message);
}
catch (SqlException ex) when (ex.Number == 2627 || ex.Number == 2601) {
    // 키 중복(3.3) — 이미 있다고 알립니다.
    ModelState.AddModelError("", "이미 등록된 값입니다.");
}
catch (SqlException ex) when (ex.Number == 1205) {
    // 교착(5.5) — 다시 시도해도 되는 오류입니다.
    return await RetryAsync();
}
catch (SqlException ex) {
    // 그 밖의 것은 기록하고 일반 오류 화면으로 보냅니다.
    _logger.LogError(ex, "프로시저 호출 실패");
    throw;
}
번호어떻게
50000 이상우리가 THROW 로 낸 것화면에 보여 줍니다
2627 · 2601키 중복"이미 있습니다" 로 바꿔 보여 줍니다
547외래 키 위반참조가 남아 있다고 알립니다
1205교착다시 시도합니다
1222잠금 시간 초과잠시 뒤 다시 시도하거나 알립니다
-2명령 시간 초과문장을 고쳐야 합니다(5부)

서버 오류 메시지를 그대로 화면에 내보내지 마십시오. 표 이름과 제약 조건 이름이 그대로 드러납니다. 50000 이상 번호만 그대로 보여 주고, 나머지는 기록만 하고 일반 문구로 바꾸십시오.

여러 행

TVP 와 트랜잭션

4.10 의 TVP 를 응용 프로그램에서 넘기는 방법입니다. DataTable 을 만들어 그대로 넘깁니다.

먼저 형식과 프로시저입니다
CREATE TYPE Board.PostNumList AS TABLE (num int NOT NULL PRIMARY KEY);
GO

CREATE OR ALTER PROCEDURE Board.P_HIT_BUMP_DEMO
    @nums Board.PostNumList READONLY
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE p SET hit_count = p.hit_count + 1
    FROM Board.POSTS p JOIN @nums n ON n.num = p.num;

    SELECT @@ROWCOUNT AS 올린행;
END
C#
var table = new DataTable();
table.Columns.Add("num", typeof(int));
foreach (var n in new[] { 1, 2, 3 }) table.Rows.Add(n);

await using var cmd = new SqlCommand("Board.P_HIT_BUMP_DEMO", con, tx) {
    CommandType = CommandType.StoredProcedure
};

var p = cmd.Parameters.AddWithValue("@nums", table);
p.SqlDbType = SqlDbType.Structured;
p.TypeName  = "Board.PostNumList";      // 스키마까지 적습니다

var affected = await cmd.ExecuteScalarAsync();
결과
TVP 로 올린 행: 3
한 번의 왕복으로 세 행을 처리했습니다. 4.10 에서 본 그대로입니다.

열은 이름이 아니라 순서로 맞춰집니다. DataTable 의 열 순서가 CREATE TYPE 의 순서와 같아야 합니다. 이름이 달라도 오류가 나지 않고 엉뚱한 열에 들어갑니다.

트랜잭션은 연결에 붙입니다

C#
await using var con = new SqlConnection(cs);
await con.OpenAsync();
await using var tx = (SqlTransaction)await con.BeginTransactionAsync(
    IsolationLevel.ReadCommitted);

try {
    await using (var c1 = new SqlCommand("…", con, tx))   // tx 를 넘겨야 합니다
        await c1.ExecuteNonQueryAsync();

    await using (var c2 = new SqlCommand("SELECT 1/0", con, tx))
        await c2.ExecuteScalarAsync();

    await tx.CommitAsync();
}
catch (SqlException ex) {
    await tx.RollbackAsync();
    Console.WriteLine($"  Number={ex.Number}  \"{ex.Message}\"  → 롤백했습니다.");
}
결과
SqlException Number=8134 "Divide by zero error encountered." → 롤백했습니다. 1번 글 조회수: 7 (원래 7)
앞의 UPDATE 도 함께 되돌아갔습니다.
지킬 것까닭
명령마다 tx 를 넘깁니다빠뜨리면 그 명령만 트랜잭션 밖에서 돕니다
한 연결 안에서만다른 연결의 명령은 이 트랜잭션에 들지 않습니다
짧게 두십시오여는 순간부터 잠금이 살아 있습니다(5.5)
사용자를 기다리지 않습니다화면 응답을 기다리는 동안 표가 잠깁니다

트랜잭션을 프로시저 안에 두는 쪽이 대개 낫습니다(4.6). 응용 프로그램은 프로시저 하나를 부르고, 묶는 일은 서버가 합니다. 왕복이 줄고 잠금 잡는 시간도 짧아집니다.

연결

열고 바로 닫습니다

SqlConnection 을 닫으면 실제로 끊기는 것이 아니라 풀로 돌아갑니다. 다음에 열 때 그것을 다시 씁니다. 그래서 오래 붙잡고 있는 것이 손해입니다.

이렇게까닭
쓸 때 열고 using 으로 닫습니다풀이 알아서 관리합니다. 아껴 쓸 까닭이 없습니다
연결을 필드에 두고 재사용하지 않습니다스레드 안전하지 않고 풀도 소용없어집니다
연결 문자열을 문자열마다 다르게 만들지 않습니다글자 하나만 달라도 풀이 따로 만들어집니다
비동기로 부릅니다기다리는 동안 스레드를 놓아 줍니다
연결 문자열에 두는 것들
Server=…;Database=MAYYANET;
Integrated Security=true;          -- 암호를 적지 않는 쪽이 낫습니다(6.1)
Encrypt=true;                      -- 최신 드라이버는 기본이 true 입니다
TrustServerCertificate=false;      -- 운영에서는 false 로 두고 인증서를 갖춥니다
Application Name=MayyaNet.Web;     -- 누가 부르는지 서버에서 보입니다
Connect Timeout=15;                -- 연결이 안 될 때 기다리는 시간
Max Pool Size=100;                 -- 기본값입니다

Application Name 을 반드시 적으십시오. 5.5 에서 본 sys.dm_exec_sessionsprogram_name 에 그 값이 보입니다. 어느 응용 프로그램이 표를 잠갔는지 그 자리에서 알 수 있습니다. 적지 않으면 전부 .Net SqlClient Data Provider 로 나옵니다.

시간 초과는 둘입니다

무엇어디에기본값넘으면
연결 시간 초과연결 문자열15초서버에 닿지 못했습니다
명령 시간 초과cmd.CommandTimeout30초SqlException Number = -2

명령 시간 초과를 늘리는 것으로 문제를 덮지 마십시오. 30초가 모자란 조회라면 그 문장을 고쳐야 합니다(5부). 늘려야 하는 것은 배치 작업처럼 원래 오래 걸리는 것뿐입니다.

이 저장소

프레임워크로 감싸면

위 코드를 프로시저마다 적으면 금세 지겨워집니다. 이 사이트가 쓰는 프레임워크(MayyaCore)는 그 되풀이를 걷어냅니다.

C# — 엔티티에 열 이름을 붙여 둡니다
public class PostItem(DataConfiguration? dbConfig) : BizItemBase(dbConfig) {

    [DataName("views", SpType.Select | SpType.List)]
    public int Views { get; set; } = 0;

    [DataName("likes", SpType.Select | SpType.List)]
    public int Likes { get; set; } = 0;
}
C# — 목록 클래스에 프로시저 이름만 적습니다
public class PostItemList(DataConfiguration? dbConfig)
    : BizItemListBase<PostItem, PostListConditions>(dbConfig) {

    protected override Dictionary<SpType, string> StoredProcedures
        => new() {
            { SpType.List, "Blog.P_POST_LIST" }
        };
}
C# — 매개 변수는 이렇게 모읍니다
public void SequenceUpdate(int id, int sequence) {
    var sp = StoredProcedures[SpType.Sequence];
    var pt = new ParameterTable {
        { GetDataName(nameof(Id))!, id },
        { GetDataName(nameof(Sequence))!, sequence }
    };

    Modifier?.AddParams(pt, SpType.Delete);
    ExecuteNonQuery(sp, pt);
}
덜어 낸 것
연결을 열고 닫는 코드 → 프레임워크가 합니다 매개 변수 형식과 크기 → 서버에서 읽어 캐시해 둡니다 결과를 속성에 담는 코드 → [DataName] 을 보고 채웁니다 동기·비동기 두 벌 → 짝으로 만들어 둡니다
열 이름을 문자열로 흩어 두지 않고 엔티티 한 곳에 모은 것이 핵심입니다.

어떤 프레임워크를 쓰든 아래는 그대로입니다. 매개 변수 형식을 맞추는 것, 오류 번호로 갈라 처리하는 것, 연결을 짧게 두는 것 — 감싸는 도구가 달라져도 그 아래에서 일어나는 일은 같습니다.

연습

직접 해보기

1. 글쓰기 화면을 이어 봅니다 난이도 하

Board.P_POST_ADD_DEMO 를 부르는 메서드를 만들어 보세요. 새 글 번호를 돌려주고, 제목이 비면 사용자에게 보여 줄 문구를 내야 합니다.

public async Task<(bool ok, int num, string? error)> AddPostAsync( int boardNum, int userNum, string title, string content, CancellationToken token = default) { await using var con = new SqlConnection(_cs); await using var cmd = new SqlCommand("Board.P_POST_ADD_DEMO", con) { CommandType = CommandType.StoredProcedure }; // 열에 적힌 그대로 형식과 길이를 지정합니다. cmd.Parameters.Add("@board_num", SqlDbType.Int).Value = boardNum; cmd.Parameters.Add("@user_num", SqlDbType.Int).Value = userNum; cmd.Parameters.Add("@title", SqlDbType.NVarChar, 200).Value = title; cmd.Parameters.Add("@content", SqlDbType.NVarChar, -1).Value = content; var num = cmd.Parameters.Add("@num", SqlDbType.Int); num.Direction = ParameterDirection.Output; try { await con.OpenAsync(token); await cmd.ExecuteNonQueryAsync(token); return (true, (int)num.Value, null); } catch (SqlException ex) when (ex.Number >= 50000) { // 우리가 THROW 로 낸 것만 그대로 보여 줍니다. return (false, 0, ex.Message); } catch (SqlException ex) { _logger.LogError(ex, "글 저장 실패 board={Board} user={User}", boardNum, userNum); return (false, 0, "저장하지 못했습니다. 잠시 뒤 다시 시도해 주십시오."); } } // 확인 // 정상 → (true, 510, null) // 빈 제목 → (false, 0, "제목을 입력하십시오.") // 눈여겨볼 것 // 1. 문자열을 문장에 이어 붙이지 않았습니다(4.9). // 2. 길이를 값이 아니라 열에 맞췄습니다. // 3. 50000 이상만 화면에 보여 주고 나머지는 감췄습니다. // 4. CancellationToken 을 끝까지 넘겼습니다.
2. 목록 화면이 가끔 시간 초과됩니다 난이도 중

목록 화면이 평소에는 빠른데 가끔 SqlException Number = -2 가 납니다. 어떤 순서로 원인을 찾겠습니까.

"평소에는 빠른데 가끔" 이 실마리입니다. 5부에서 그 증상을 두 번 보았습니다.
-- -2 는 명령 시간 초과입니다. CommandTimeout 을 늘리는 것은 마지막입니다. -- 1. 서버에서 무엇을 기다리는지 먼저 봅니다(5.5). SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, s.program_name, LEFT(t.text, 80) AS 문장 FROM sys.dm_exec_requests r JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t WHERE r.session_id > 50; -- blocking_session_id 가 0 이 아니면 잠금 문제입니다. -- program_name 에 Application Name 이 보이면 어느 앱인지도 압니다. -- 2. 잠금이 아니라면 계획을 봅니다. -- "평소 빠른데 가끔" 은 매개 변수 스니핑의 전형입니다(5.3). SELECT TOP (20) qs.execution_count, qs.total_logical_reads / qs.execution_count AS 평균읽기, qs.min_worker_time / 1000 AS 최소ms, qs.max_worker_time / 1000 AS 최대ms, LEFT(t.text, 100) AS 문장 FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t WHERE t.text LIKE '%P_POST_LIST%' ORDER BY qs.max_worker_time DESC; -- 최소와 최대가 크게 벌어지면 스니핑입니다. -- 같은 문장인데 어떤 값에서만 느린 것입니다. -- 3. 응용 프로그램 쪽도 봅니다. -- AddWithValue 를 썼다면 계획이 길이마다 쌓입니다. -- 새 길이가 들어올 때마다 컴파일이 일어납니다. SELECT COUNT(*) AS 계획수, LEFT(t.text, 60) AS 문장 FROM sys.dm_exec_cached_plans p CROSS APPLY sys.dm_exec_sql_text(p.plan_handle) t WHERE t.text LIKE '%Board.POSTS%' GROUP BY LEFT(t.text, 60) HAVING COUNT(*) > 1; -- 4. 고칩니다. 원인에 따라 다릅니다. -- 잠금 → 트랜잭션을 짧게, RCSI 를 켬(5.5) -- 스니핑 → OPTION (RECOMPILE) 또는 OPTIMIZE FOR (5.3) -- 계획 오염 → 매개 변수 형식과 길이를 지정 -- 문장 자체 → 인덱스와 조건 모양(5.2 · 5.4) -- 5. CommandTimeout 은 마지막입니다. -- 늘려서 되는 것은 "원래 오래 걸리는 작업" 뿐입니다. -- 목록 화면이 30초 넘게 걸린다면 늘릴 것이 아니라 고칠 것입니다.
요약
  • 프로시저는 CommandType.StoredProcedure 로 부릅니다. 리더를 닫아야 OUTPUTReturnValue 가 채워집니다.
  • AddWithValue 를 쓰지 마십시오. 값의 길이로 크기를 정해(NVarChar(10)) 같은 문장의 계획이 길이마다 쌓입니다 — 다섯 번 불러 계획 다섯 개, 길이를 지정하면 하나입니다.
  • 형식과 길이는 열에 적힌 그대로 지정합니다. max-1 입니다. 5.4 의 암시적 형 변환도 여기서 막습니다.
  • THROW 50001SqlException.Number = 50001 로 그대로 옵니다. 50000 이상만 화면에 보여 주고 나머지는 기록만 하십시오.
  • 오류 번호로 갈라 처리합니다 — 2627·2601(중복) · 547(외래 키) · 1205(교착, 다시 시도) · -2(명령 시간 초과).
  • TVP 는 DataTable 에 담아 SqlDbType.StructuredTypeName 으로 넘깁니다. 열은 이름이 아니라 순서로 맞춰집니다.
  • 트랜잭션은 명령마다 tx 를 넘겨야 합니다. 되도록 프로시저 안에 두는 편이 낫습니다(4.6).
  • 연결은 쓸 때 열고 바로 닫습니다. 풀이 관리하므로 아껴 쓸 까닭이 없습니다.
  • Application Name 을 연결 문자열에 적으십시오. 어느 응용 프로그램이 잠갔는지 서버에서 바로 보입니다(5.5).
  • 명령 시간 초과를 늘려 문제를 덮지 마십시오. 30초가 모자란 조회는 늘릴 것이 아니라 고칠 것입니다.