응용 프로그램에서 부르기
ADO.NET 으로 프로시저를 호출하고 매개 변수를 넘깁니다.
프로시저를 부릅니다
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
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}");
리더를 닫기 전에는 OUTPUT 값이 비어
있습니다. 결과 집합을 다 읽어야 서버가 마지막 패킷에 그 값을
실어 보내기 때문입니다. total.Value 가
null 로 나온다면 리더를 아직 닫지
않은 것입니다.
AddWithValue 가 계획을 늘립니다
AddWithValue 는 편해 보입니다. 형식을 적지
않아도 되니까요. 그 형식을 값에서 짐작하는 것이 문제입니다.
cmd.Parameters.AddWithValue("@t", "인덱스 질문드립니다"); var p = cmd.Parameters["@t"]; Console.WriteLine($" 형식: {p.SqlDbType}({p.Size})");
크기가 다르면 서버가 다른 문장으로 봅니다. 같은 SQL 을 길이가 다른 다섯 문자열로 불러 보았습니다.
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번 부른 뒤 계획 수 |
|---|---|
| AddWithValue | 5 |
| 형식과 길이를 지정 | 1 |
계획 캐시가 쓸데없이 커지고 컴파일이 되풀이됩니다. 5.3 에서
본 컴파일 비용이 매개 변수 하나를 어떻게 적었느냐로
늘어납니다. 게다가 5.4 의 암시적 형 변환도 여기서
생깁니다 — 열이 varchar 인데
AddWithValue 가
nvarchar 로 보내면 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
try { await cmd.ExecuteNonQueryAsync(); Console.WriteLine($" 넣었습니다. num = {num.Value}"); } catch (SqlException ex) { Console.WriteLine($" Number={ex.Number} Class={ex.Class} \"{ex.Message}\""); }
오류 번호로 갈라 처리할 수 있습니다. 4.5 에서 번호를 정해 두라고 한 값이 여기서 드러납니다.
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
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();
열은 이름이 아니라 순서로 맞춰집니다.
DataTable 의 열 순서가 CREATE
TYPE 의 순서와 같아야 합니다. 이름이 달라도 오류가 나지 않고
엉뚱한 열에 들어갑니다.
트랜잭션은 연결에 붙입니다
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}\" → 롤백했습니다."); }
| 지킬 것 | 까닭 |
|---|---|
| 명령마다 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_sessions
의 program_name 에 그 값이 보입니다.
어느 응용 프로그램이 표를 잠갔는지 그 자리에서 알 수 있습니다.
적지 않으면 전부 .Net SqlClient Data Provider
로 나옵니다.
시간 초과는 둘입니다
| 무엇 | 어디에 | 기본값 | 넘으면 |
|---|---|---|---|
| 연결 시간 초과 | 연결 문자열 | 15초 | 서버에 닿지 못했습니다 |
| 명령 시간 초과 | cmd.CommandTimeout | 30초 | SqlException Number = -2 |
명령 시간 초과를 늘리는 것으로 문제를 덮지 마십시오. 30초가 모자란 조회라면 그 문장을 고쳐야 합니다(5부). 늘려야 하는 것은 배치 작업처럼 원래 오래 걸리는 것뿐입니다.
프레임워크로 감싸면
위 코드를 프로시저마다 적으면 금세 지겨워집니다. 이 사이트가 쓰는
프레임워크(MayyaCore)는 그 되풀이를 걷어냅니다.
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; }
public class PostItemList(DataConfiguration? dbConfig) : BizItemListBase<PostItem, PostListConditions>(dbConfig) { protected override Dictionary<SpType, string> StoredProcedures => new() { { SpType.List, "Blog.P_POST_LIST" } }; }
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); }
어떤 프레임워크를 쓰든 아래는 그대로입니다. 매개 변수 형식을 맞추는 것, 오류 번호로 갈라 처리하는 것, 연결을 짧게 두는 것 — 감싸는 도구가 달라져도 그 아래에서 일어나는 일은 같습니다.
직접 해보기
Board.P_POST_ADD_DEMO 를 부르는 메서드를
만들어 보세요. 새 글 번호를 돌려주고, 제목이 비면 사용자에게
보여 줄 문구를 내야 합니다.
목록 화면이 평소에는 빠른데 가끔
SqlException Number = -2 가 납니다.
어떤 순서로 원인을 찾겠습니까.
- 프로시저는
CommandType.StoredProcedure로 부릅니다. 리더를 닫아야OUTPUT과ReturnValue가 채워집니다. AddWithValue를 쓰지 마십시오. 값의 길이로 크기를 정해(NVarChar(10)) 같은 문장의 계획이 길이마다 쌓입니다 — 다섯 번 불러 계획 다섯 개, 길이를 지정하면 하나입니다.- 형식과 길이는 열에 적힌 그대로 지정합니다.
max는-1입니다. 5.4 의 암시적 형 변환도 여기서 막습니다. THROW 50001은SqlException.Number = 50001로 그대로 옵니다. 50000 이상만 화면에 보여 주고 나머지는 기록만 하십시오.- 오류 번호로 갈라 처리합니다 — 2627·2601(중복) · 547(외래 키) · 1205(교착, 다시 시도) · -2(명령 시간 초과).
- TVP 는
DataTable에 담아SqlDbType.Structured와TypeName으로 넘깁니다. 열은 이름이 아니라 순서로 맞춰집니다. - 트랜잭션은 명령마다
tx를 넘겨야 합니다. 되도록 프로시저 안에 두는 편이 낫습니다(4.6). - 연결은 쓸 때 열고 바로 닫습니다. 풀이 관리하므로 아껴 쓸 까닭이 없습니다.
Application Name을 연결 문자열에 적으십시오. 어느 응용 프로그램이 잠갔는지 서버에서 바로 보입니다(5.5).- 명령 시간 초과를 늘려 문제를 덮지 마십시오. 30초가 모자란 조회는 늘릴 것이 아니라 고칠 것입니다.