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

변수와 흐름 제어

DECLARE 로 값을 담고 IF · WHILE 로 조건에 따라 다른 문장을 실행합니다. 배치와 GO, 그리고 조용히 틀리는 자리 셋을 봅니다.

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

SQL 에 절차를 적습니다

3부까지는 무엇을 원하는지만 적었습니다. 어떻게 가져올지는 엔진이 정했습니다. 그런데 실제 작업에는 순서가 있습니다 — 3.9 의 답글 넣기가 그랬습니다. 부모를 찾고, 뒤를 밀고, 넣습니다.

T-SQL 은 SQL 에 절차를 적을 수 있게 더한 것입니다. 값을 담는 변수, 조건에 따라 갈라지는 IF, 되풀이하는 WHILE 이 있습니다. 4부는 이 문법으로 시작해 저장 프로시저까지 갑니다.

다만 이 단원의 결론을 먼저 적어 둡니다. 절차를 적을 수 있다는 것과 적어야 한다는 것은 다릅니다. 마지막 절에서 507번 도는 반복문과 한 문장을 견줍니다.

변수

값을 담습니다

DECLARE 로 선언하고 이름은 @ 로 시작합니다. 선언과 함께 값을 줄 수 있습니다.

SQL
DECLARE @board int = 2;
DECLARE @name nvarchar(50), @cnt int;   -- 값을 주지 않으면 NULL 입니다

-- 대입하는 방법이 둘입니다.
SET @name = (SELECT name FROM Board.BOARDS WHERE num = @board);
SELECT @cnt = COUNT(*) FROM Board.POSTS WHERE board_num = @board;

SELECT @name AS 게시판, @cnt AS 글수;
결과
게시판글수
자유게시판315

SET 과 SELECT 은 다르게 실패합니다

둘 다 값을 담지만 맞는 행이 없을 때와 여러 개일 때 다르게 움직입니다. 이것이 조용히 틀리는 자리입니다.

SQL
-- 맞는 행이 하나도 없는 조건입니다.
DECLARE @a int = 999, @b int = 999;

SET @a = (SELECT hit_count FROM Board.POSTS WHERE num = -1);
SELECT @b = hit_count FROM Board.POSTS WHERE num = -1;

SELECT @a AS SET으로, @b AS SELECT으로;
결과
SET 으로SELECT 으로
NULL999
SELECT 은 대입 자체가 일어나지 않아 이전 값이 그대로 남습니다.

이것이 위험한 까닭은 오류가 나지 않기 때문입니다. 반복문 안에서 SELECT 로 대입하면 조건에 맞는 행이 없는 회차에서 지난 회차의 값이 그대로 남아 그것을 다시 사용하게 됩니다.

여러 행이 나올 때도 갈립니다.

SQL
-- 공지사항 35건이 나오는 조건입니다.
DECLARE @c int;
SELECT @c = hit_count FROM Board.POSTS WHERE board_num = 1;
SELECT @c AS 남은값;
GO
DECLARE @d int;
SET @d = (SELECT hit_count FROM Board.POSTS WHERE board_num = 1);
결과 — SELECT 은 조용히 넘어갑니다
마지막으로 읽힌 행의 값 하나가 남습니다. 어느 행인지는 정해져 있지 않습니다.
오류 — SET 은 막습니다
메시지 512 Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.

값 하나를 담을 때는 SET 을 사용하십시오. 조건이 잘못되었으면 그 자리에서 알려 줍니다. SELECT 대입은 여러 변수에 한 번에 담을 때가 제 자리입니다 — SELECT @grp = group_num, @dep = depth + 1 FROM … 처럼 3.9 에서 사용한 방식입니다.

@@ROWCOUNT 는 바로 다음 문장에서만 유효합니다

SQL
BEGIN TRAN;
UPDATE Board.POSTS SET hit_count = hit_count WHERE board_num = 1;
SELECT @@ROWCOUNT AS 고친행수;
SELECT @@ROWCOUNT AS 한문장뒤;
ROLLBACK;
결과
언제 읽었나
UPDATE 바로 뒤35
한 문장 뒤1
앞의 SELECT 이 1행을 냈으므로 값이 1 로 바뀌었습니다.

모든 문장이 이 값을 다시 씁니다. SELECT 도, IF 안의 조회도 그렇습니다. 필요하면 바로 변수에 담으십시오DECLARE @n int = @@ROWCOUNT; 입니다.

배치

GO 는 SQL 문장이 아닙니다

SQL Server 로 보내는 단위를 배치라고 합니다. GO 는 그 경계를 나타내는 표시이고, 서버가 아니라 도구가 읽습니다. SSMS 와 sqlcmdGO 를 만나면 그때까지의 문장을 한 덩어리로 보냅니다.

그래서 변수는 배치를 넘지 못합니다.

SQL
DECLARE @e int = 1;
GO
PRINT @e;
오류
메시지 137 Must declare the scalar variable "@e".
선언은 앞 배치에서 끝났습니다. 다음 배치는 그것을 모릅니다.

같은 배치에서 이름을 두 번 선언할 수도 없습니다.

SQL
DECLARE @x int;
DECLARE @x int;
오류
메시지 134 The variable name '@x' has already been declared. Variable names must be unique within a query batch or stored procedure.

GO 뒤에 숫자를 적으면 그 배치를 그만큼 되풀이합니다. 시험용 자료를 만들 때 편리합니다.

SQL
PRINT N'세 번 나옵니다';
GO 3
결과
세 번 나옵니다 세 번 나옵니다 세 번 나옵니다
되풀이되는 것은 앞 GO 이후의 배치 전체입니다. 한 문장이 아닙니다.
조건

IF 와 ELSE

조건에 따라 다른 문장을 실행합니다. 2.8 의 CASE 는 값 하나를 고르는 식이었고, IF문장을 고르는 것입니다.

SQL
IF EXISTS (SELECT 1 FROM Board.POSTS WHERE board_num = 1)
    PRINT N'공지사항에 글이 있습니다';
ELSE
    PRINT N'없습니다';
결과
공지사항에 글이 있습니다
EXISTS 는 한 건만 찾으면 멈춥니다. COUNT(*) > 0 보다 이쪽이 낫습니다.

BEGIN 과 END 를 빠뜨리면

IF바로 다음 한 문장만 거느립니다. 두 문장을 넣으려면 묶어야 합니다.

SQL
DECLARE @cnt int = 0;

IF @cnt > 0
    PRINT N'글이 있습니다';
    PRINT N'이 줄은 조건과 상관없이 나옵니다';
결과
이 줄은 조건과 상관없이 나옵니다
들여쓰기가 같아 한 덩어리로 보이지만 둘째 줄은 IF 밖입니다.

들여쓰기는 아무 뜻이 없습니다. 조건이 거짓인데도 둘째 줄이 실행되었습니다. 이 자리에 UPDATEDELETE 가 있었다면 조건과 상관없이 자료가 바뀝니다.

SQL
IF @cnt > 0
BEGIN
    PRINT N'글이 있습니다';
    PRINT N'두 줄 모두 조건 안입니다';
END
PRINT N'--- 블록 밖 ---';
결과
--- 블록 밖 ---
이번에는 두 줄 모두 나오지 않았습니다.

한 문장이라도 BEGIN · END 로 묶는 습관을 들이십시오. 나중에 줄을 하나 더할 때 이 함정을 밟게 됩니다.

블록 안에서 선언해도 배치 전체입니다

다른 언어의 블록 범위를 떠올리면 어긋납니다.

SQL
IF 1 = 0
BEGIN
    DECLARE @never int = 1;
END
SELECT @never AS 값;
결과
NULL
오류가 아닙니다. 선언은 살아 있고 대입만 일어나지 않았습니다.

선언은 배치를 컴파일할 때 처리되고 대입은 실행할 때 일어납니다. 그래서 조건이 거짓이어도 변수는 있고, 값은 NULL 입니다. DECLARE 는 블록 안이 아니라 배치 앞머리에 모아 두는 편이 읽기 좋습니다.

반복

WHILE

조건이 참인 동안 되풀이합니다. BREAK 로 빠져나오고 CONTINUE 로 다음 회차로 건너뜁니다.

SQL
DECLARE @i int = 1, @sum int = 0;

WHILE @i <= 10
BEGIN
    IF @i = 6 BREAK;
    SET @sum += @i;
    SET @i += 1;
END

SELECT @i AS 멈춘자리, @sum AS 합;
결과
멈춘자리
615
1 에서 5 까지 더하고 6 에서 나왔습니다.

증가시키는 줄을 빠뜨리면 끝나지 않습니다. 위에서 SET @i += 1CONTINUE 뒤에 있으면 그 회차에서 건너뛰어 무한히 돕니다. CONTINUE 를 사용할 때는 증가 문장이 그보다 앞에 있는지 확인하십시오.

방향

그런데 되도록 반복하지 마십시오

게시판별 글 수를 세는 일을 두 가지로 적어 봅니다. 하나는 배운 문법을 모두 사용하고, 다른 하나는 3부까지의 방식입니다.

SQL
-- (가) 한 건씩 읽으면서 셉니다.
DECLARE @i int = 1, @max int = (SELECT MAX(num) FROM Board.POSTS),
        @c1 int = 0, @c2 int = 0, @c3 int = 0;
WHILE @i <= @max
BEGIN
    DECLARE @b int = (SELECT board_num FROM Board.POSTS WHERE num = @i);
    IF @b = 1 SET @c1 += 1;
    ELSE IF @b = 2 SET @c2 += 1;
    ELSE IF @b = 3 SET @c3 += 1;
    SET @i += 1;
END
SELECT @c1 AS 공지, @c2 AS 자유, @c3 AS 질문;

-- (나) 한 문장으로 셉니다.
SELECT board_num, COUNT(*) AS 글수 FROM Board.POSTS GROUP BY board_num;
결과 — 값은 같습니다
공지자유질문
35315157
읽은 것
방식엔진에 보낸 조회논리적 읽기
WHILE 로 한 건씩5071,014
한 문장으로116
STATISTICS IO 를 켜면 위쪽은 같은 줄이 507번, 아래쪽은 한 줄 나옵니다.

507번 물어본 것과 한 번 물어본 것의 차이입니다. 반복문은 한 번에 한 행씩 다루므로, 행이 늘면 오가는 횟수도 그대로 늡니다. 한 문장은 무엇을 원하는지만 적고 어떻게 모을지는 엔진이 정합니다 — 3.5 에서 본 인덱스도 그때 고려됩니다.

507행이라 차이가 시간으로는 잘 드러나지 않습니다. 그러나 이 구조는 행 수에 비례해 벌어집니다. 50만 행이면 50만 번 물어보게 됩니다.

반복문이 필요한 자리가 없지는 않습니다. 큰 삭제를 끊어서 하는 경우(5.7), 배치 작업을 나누는 경우가 그렇습니다. 그러나 한 문장으로 적을 수 있는 일을 반복문으로 적지는 마십시오. 커서까지 포함해 4.8 에서 다시 다룹니다.

연습

직접 해보기

1. 없는 글을 조회했는데 값이 나옵니다 난이도 하

아래 코드는 없는 글 번호를 넣었는데도 조회수 -1 을 냅니다. 까닭을 말하고, 없으면 없다고 알리도록 고쳐 보세요.

SQL
DECLARE @hit int = -1;
SELECT @hit = hit_count FROM Board.POSTS WHERE num = 99999;
PRINT CONCAT(N'조회수 ', @hit);
-- 맞는 행이 없으면 SELECT 대입은 아예 일어나지 않습니다. -- 그래서 초기값 -1 이 그대로 남고, 그것을 조회수로 착각합니다. DECLARE @hit int = -1; SET @hit = (SELECT hit_count FROM Board.POSTS WHERE num = 99999); IF @hit IS NULL PRINT N'그런 글이 없습니다'; ELSE PRINT CONCAT(N'조회수 ', @hit); -- 그런 글이 없습니다 -- num = 250 으로 바꾸면 "조회수 250" 이 나옵니다. -- SET 은 맞는 행이 없으면 NULL 을 넣습니다. 값이 없다는 사실이 값으로 남습니다(1.9). -- IF @hit = NULL 로 적으면 안 됩니다. NULL 과의 비교는 참이 되지 않습니다.
2. 답글 넣기에 관문을 답니다 난이도 중

3.9 에서 답글 넣기를 세 문장으로 적었습니다. 그런데 없는 글에 답글을 달려고 하면 부모를 찾지 못해 group_numNULL 인 채로 넣게 됩니다. 먼저 확인하고 없으면 알리도록 고쳐 보세요. 몇 행을 밀었는지도 함께 냅니다.

IF EXISTS 로 관문을 두고, 밀린 행 수는 UPDATE 바로 뒤에서 변수에 담습니다.
BEGIN TRAN; DECLARE @parent int = 6, @grp int, @dep smallint, @sort int, @moved int; IF NOT EXISTS (SELECT 1 FROM Board.POSTS WHERE num = @parent) BEGIN PRINT N'없는 글에는 답글을 달 수 없습니다'; END ELSE BEGIN SELECT @grp = group_num, @dep = depth + 1, @sort = sort_no + 1 FROM Board.POSTS WHERE num = @parent; UPDATE Board.POSTS SET sort_no = sort_no + 1 WHERE group_num = @grp AND sort_no >= @sort; SET @moved = @@ROWCOUNT; -- UPDATE 바로 뒤여야 합니다 INSERT INTO Board.POSTS (board_num, category_num, user_num, parent_num, group_num, depth, sort_no, title, hit_count, reg_date) SELECT board_num, category_num, 1, @parent, @grp, @dep, @sort, N'[답글] 관문을 지난 답글', 0, '2026-03-01' FROM Board.POSTS WHERE num = @parent; SELECT @moved AS 밀린행, SCOPE_IDENTITY() AS 새글번호; END ROLLBACK; -- 밀린행 새글번호 -- 2 508 -- @parent 를 99999 로 바꾸면 "없는 글에는 답글을 달 수 없습니다" 만 나오고 -- 표는 하나도 바뀌지 않습니다. -- SET @moved 를 INSERT 뒤로 옮기면 값이 1 이 됩니다. 마지막 문장이 다시 쓰기 때문입니다. -- 여기까지가 4.1 로 할 수 있는 데까지입니다. 이 절차를 부를 수 있는 이름에 -- 담고 매개 변수를 받게 하는 것이 4.2 와 4.3 이고, -- 중간에 끊겼을 때를 다루는 것이 4.5 와 4.6 입니다.
요약
  • 변수는 DECLARE 로 선언하고 값을 주지 않으면 NULL 입니다.
  • 맞는 행이 없을 때 SETNULL 을 넣고 SELECT 대입은 이전 값을 그대로 둡니다. 값 하나를 담을 때는 SET 을 사용하십시오.
  • 여러 행이 나오면 SET512 로 막고 SELECT 은 조용히 하나만 남깁니다.
  • @@ROWCOUNT바로 다음 문장에서만 유효합니다(35 → 1). 필요하면 즉시 변수에 담으십시오.
  • GO 는 SQL 문장이 아니라 도구가 읽는 배치 구분자입니다. 변수는 배치를 넘지 못합니다(137).
  • IF바로 다음 한 문장만 거느립니다. 들여쓰기는 뜻이 없으므로 BEGIN · END 로 묶으십시오.
  • 블록 안에서 DECLARE 해도 배치 전체에 유효합니다. 조건이 거짓이면 선언만 남고 값은 NULL 입니다.
  • 한 문장으로 적을 수 있는 일을 반복문으로 적지 마십시오. 507행을 세는 데 조회 507번(1,014장)과 조회 한 번(16장)의 차이입니다.