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

커서와 집합 기반 사고

한 행씩 도는 코드를 집합을 다루는 문장으로 바꿉니다. 4.1 에서 미뤄 둔 것을 여기서 갚습니다.

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

결과를 한 행씩 읽는 장치

4.1 에서 반복문으로 507번 물어보는 코드를 보았습니다. 커서는 그것을 위한 전용 장치입니다. 결과 집합을 열어 두고 한 행씩 꺼냅니다.

SQL
DECLARE @b int, @c1 int = 0, @c2 int = 0, @c3 int = 0;

DECLARE cur CURSOR FOR SELECT board_num FROM Board.POSTS;   -- 선언
OPEN cur;                                                   -- 열고
FETCH NEXT FROM cur INTO @b;                             -- 한 행 꺼내고

WHILE @@FETCH_STATUS = 0                                  -- 꺼낸 것이 있으면
BEGIN
    IF @b = 1 SET @c1 += 1; ELSE IF @b = 2 SET @c2 += 1; ELSE SET @c3 += 1;
    FETCH NEXT FROM cur INTO @b;
END

CLOSE cur;                                                  -- 닫고
DEALLOCATE cur;                                            -- 버립니다
결과
공지자유질문
35315157
한 문장으로 적으면 GROUP BY 하나입니다.

문법이 길다는 것보다 더 큰 문제가 있습니다. 얼마나 비싼지 재 봅니다.

비용

5만 행에 4.8초와 0.07초

덩어리마다 순번을 매기는 일입니다. 3.9 의 sort_no 를 다시 매기는 작업이 이 모양입니다.

SQL
-- 5만 행짜리 표를 만들어 둡니다. grp 는 500 가지입니다.
-- (가) 커서로 한 행씩 읽으며 갱신합니다.
DECLARE @num int, @grp int, @prev int = -1, @seq int = 0;

DECLARE cur CURSOR FOR SELECT num, grp FROM Board.T_CUR ORDER BY grp, num;
OPEN cur;
FETCH NEXT FROM cur INTO @num, @grp;
WHILE @@FETCH_STATUS = 0
BEGIN
    IF @grp <> @prev BEGIN SET @seq = 1; SET @prev = @grp; END
    ELSE SET @seq += 1;

    UPDATE Board.T_CUR SET seq = @seq WHERE num = @num;
    FETCH NEXT FROM cur INTO @num, @grp;
END
CLOSE cur; DEALLOCATE cur;

-- (나) 윈도 함수로 한 문장에 끝냅니다(2.7).
WITH T AS (
    SELECT seq, ROW_NUMBER() OVER (PARTITION BY grp ORDER BY num) AS rn
    FROM Board.T_CUR
)
UPDATE T SET seq = rn;
결과 — 5만 행
방식밀리초
커서로 한 행씩4,770~4,903
윈도 함수 한 문장64~67
여러 번 돌려 얻은 범위입니다. 결과는 같고 70 배쯤 차이가 납니다.

커서는 5만 번 왕복합니다. 행마다 꺼내고, 판단하고, UPDATE 문장을 따로 실행합니다. 한 문장은 무엇을 원하는지만 적고 어떻게 처리할지는 엔진이 정합니다 — 정렬도 한 번, 갱신도 한 번입니다.

옵션을 붙이면 빨라집니다. 그래도 멉니다

기본 커서는 가장 무거운 조합으로 만들어집니다. 읽기만 할 것이라면 그렇게 말해 주어야 합니다.

SQL
DECLARE c1 CURSOR FOR-- 기본
DECLARE c2 CURSOR LOCAL FAST_FORWARD FOR-- 앞으로만, 읽기만
결과 — 5만 행을 읽기만
방식밀리초
기본 커서475~499
LOCAL FAST_FORWARD189~199
한 문장(COUNT)1
옵션으로 2.5 배 빨라졌지만 한 문장의 200 배쯤입니다.

기본 커서가 무거운 까닭은 속성을 보면 드러납니다.

SQL
SELECT name AS 커서, properties AS 속성, is_open AS 열림
FROM sys.dm_exec_cursors(@@SPID) WHERE name IS NOT NULL;
결과
커서속성열림
cur_leakTSQL | Dynamic | Optimistic | Global (0)1
Dynamic 은 매번 원본을 다시 봅니다. Optimistic 은 갱신 충돌을 확인합니다.

이 커서는 닫지 않고 빠져나온 것입니다. Global 이라 배치가 끝나도 남아 있고, 연결이 끊길 때까지 그대로입니다. LOCAL 을 붙이면 배치가 끝날 때 저절로 사라집니다. 커서를 만든다면 LOCAL FAST_FORWARD 를 기본으로 삼으십시오.

걷어내기

커서로 하던 일을 문장으로

커서를 쓰게 되는 자리는 몇 가지로 정해져 있습니다. 대부분 2.7 의 윈도 함수로 풀립니다.

순번 매기기 → ROW_NUMBER

SQL
-- 앞에서 본 것입니다. 덩어리마다 0 부터 다시 셉니다.
WITH T AS (
    SELECT sort_no,
           ROW_NUMBER() OVER (PARTITION BY group_num ORDER BY sort_no, num) - 1 AS rn
    FROM Board.POSTS
)
UPDATE T SET sort_no = rn;

이전 행과 비교하기 → LAG

"앞 글보다 조회수가 얼마나 늘었는가" 는 커서로 하면 이전 값을 변수에 들고 다녀야 합니다. LAG 는 그것을 문장 안에서 합니다.

SQL
SELECT TOP (5) num, hit_count,
       LAG(hit_count) OVER (ORDER BY num) AS 앞글,
       hit_count - LAG(hit_count) OVER (ORDER BY num) AS 차이
FROM Board.POSTS WHERE board_num = 1 ORDER BY num;
결과
numhit_count앞글차이
1070NULLNULL
201407070
3021014070
4028021070
첫 행은 앞이 없으므로 NULL 입니다(1.9).

누적 합계 → SUM OVER

SQL
SELECT TOP (5) num, hit_count,
       SUM(hit_count) OVER (ORDER BY num ROWS UNBOUNDED PRECEDING) AS 누적
FROM Board.POSTS WHERE board_num = 1 ORDER BY num;
결과
numhit_count누적
107070
20140210
30210420
40280700
누적은 커서로 짜기 쉬운 대표적인 것입니다. 여기서는 한 줄입니다.

문자열 이어 붙이기 → STRING_AGG

SQL
-- 커서로 하면 변수에 계속 이어 붙여야 합니다.
DECLARE @r nvarchar(max) = N'', @name nvarchar(50);
DECLARE c CURSOR LOCAL FAST_FORWARD FOR SELECT nickname FROM Member.USERS ORDER BY num;
… 여덟 줄 …

-- 한 줄입니다.
SELECT STRING_AGG(nickname, N', ') WITHIN GROUP (ORDER BY num) FROM Member.USERS;
결과 — 둘 다 같습니다
홍길동, 김철수, 이영희, 박민수, 최지우, 정하늘, 강바다, 윤서준, …

바꿔 놓고 보는 표

커서로 하던 일대신 사용할 것배운 곳
순번 매기기ROW_NUMBER()2.7
이전·다음 행 참조LAG · LEAD2.7
누적 합계SUM() OVER2.7
문자열 이어 붙이기STRING_AGG2.2
행마다 다른 값 넣기CASE2.8
계층 따라 내려가기재귀 CTE2.5
있으면 고치고 없으면 넣기MERGE 또는 두 문장1.8

커서를 만들고 싶어지면 이 표를 먼저 보십시오. 대부분 이미 배운 문법으로 풀립니다. 새로 배울 것이 있어서 커서를 쓰는 것이 아니라, 절차로 생각하는 습관 때문에 커서를 쓰게 됩니다.

예외

커서가 맞는 자리도 있습니다

  • 행마다 프로시저를 불러야 할 때. 표마다 인덱스를 다시 만들거나, 데이터베이스마다 같은 작업을 하는 관리 스크립트가 그렇습니다. 집합으로 적을 수 없는 일입니다.
  • 행마다 실패해도 나머지를 계속해야 할 때. 한 문장은 전부 되거나 전부 안 됩니다(4.6). 1,000건 중 3건이 잘못되었어도 997건은 넣어야 한다면 한 건씩 다루어야 합니다.
  • 큰 작업을 끊어서 할 때. 500만 행을 한 문장으로 지우면 로그가 가득 차고 잠금이 오래 유지됩니다. 이때는 끊어서 도는데, 커서보다는 WHILETOP 을 쓰는 편이 낫습니다. 5.7 에서 다룹니다.

공통점이 보입니다 — 셋 다 "자료를 계산하는 일" 이 아니라 "작업을 반복하는 일" 입니다. 자료를 다루는 일이라면 집합으로 적을 수 있고, 작업을 반복하는 일이라면 반복문이 맞습니다.

연습

직접 해보기

1. 커서를 걷어냅니다 난이도 하

아래 코드는 게시판별 조회수 합계를 냅니다. 한 문장으로 바꿔 보세요.

SQL
DECLARE @t TABLE (b int PRIMARY KEY, s bigint);
DECLARE @b int, @h int;

DECLARE c CURSOR LOCAL FAST_FORWARD FOR SELECT board_num, hit_count FROM Board.POSTS;
OPEN c; FETCH NEXT FROM c INTO @b, @h;
WHILE @@FETCH_STATUS = 0
BEGIN
    IF EXISTS (SELECT 1 FROM @t WHERE b = @b)
        UPDATE @t SET s = s + @h WHERE b = @b;
    ELSE
        INSERT INTO @t VALUES (@b, @h);
    FETCH NEXT FROM c INTO @b, @h;
END
CLOSE c; DEALLOCATE c;

SELECT b AS 게시판, s AS 조회수합 FROM @t ORDER BY b;
SELECT board_num AS 게시판, SUM(CAST(hit_count AS bigint)) AS 조회수합 FROM Board.POSTS GROUP BY board_num ORDER BY board_num; -- 게시판 조회수합 -- 1 9100 -- 2 59462 -- 3 29959 -- 열여섯 줄이 네 줄이 되었습니다. -- CAST 를 붙인 것은 int 합계가 넘칠 수 있기 때문입니다(1.3). -- 커서 쪽도 표 변수를 bigint 로 잡아 두었습니다. -- "있으면 더하고 없으면 넣는" 절차가 GROUP BY 한 줄에 들어 있습니다. -- 커서로 짜면 그 판단을 사람이 적어야 하고, 그래서 틀릴 수 있습니다.
2. 어긋난 차례를 다시 매깁니다 난이도 중

4.5 에서 보았듯 답글 넣기가 반쪽만 끝나면 sort_no 에 빈 자리가 생깁니다. 덩어리마다 0 부터 빈틈없이 다시 매기는 문장을 적어 보세요. 커서는 사용하지 않습니다.

덩어리마다 다시 세어야 합니다. 2.7 의 어느 절이 그 일을 합니까. CTE 를 UPDATE 할 수 있다는 것도 떠올리십시오(2.5).
-- 일부러 어긋뜨려 두고 시작합니다. BEGIN TRAN; UPDATE Board.POSTS SET sort_no = sort_no * 10 WHERE group_num IN (6, 9, 12); SELECT num, group_num, sort_no FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no; -- 6 6 0 -- 352 6 10 -- 456 6 20 -- 덩어리마다 0 부터 다시 매깁니다. WITH T AS ( SELECT sort_no, ROW_NUMBER() OVER (PARTITION BY group_num ORDER BY sort_no, num) - 1 AS rn FROM Board.POSTS ) UPDATE T SET sort_no = rn; -- 507 행 SELECT num, group_num, sort_no FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no; -- 6 6 0 -- 352 6 1 -- 456 6 2 -- 모든 덩어리가 0 부터 빈틈없는지 확인합니다. SELECT COUNT(*) AS 어긋난덩어리 FROM ( SELECT group_num FROM Board.POSTS GROUP BY group_num HAVING MAX(sort_no) <> COUNT(*) - 1 OR MIN(sort_no) <> 0) X; -- 0 ROLLBACK; -- ORDER BY 에 sort_no 를 먼저 둔 것이 중요합니다. -- 지금 차례를 지키면서 번호만 촘촘히 하려는 것이지, 순서를 바꾸려는 것이 아닙니다. -- num 을 두 번째로 둔 것은 sort_no 가 같은 행이 있을 때를 위한 것입니다. -- CTE 를 UPDATE 하는 것이 낯설 수 있습니다. 대상이 한 표로 정해지면 됩니다(3.7).

이 단원에서 만든 표를 지웁니다.
DROP TABLE Board.T_CUR;

요약
  • 커서는 결과를 한 행씩 읽는 장치입니다. 선언 · 열기 · 꺼내기 · 닫기 · 버리기의 다섯 단계입니다.
  • 5만 행에 4,800 밀리초 대와 60 밀리초 대입니다. 커서는 행마다 왕복하고 한 문장은 엔진이 한 번에 처리합니다.
  • LOCAL FAST_FORWARD 를 붙이면 2.5 배 빨라지지만 그래도 한 문장의 200 배쯤입니다.
  • 기본 커서는 Dynamic | Optimistic | Global 로 만들어집니다. 닫지 않으면 연결이 끊길 때까지 남습니다.
  • 커서로 하던 일은 대개 2.7 의 윈도 함수로 풀립니다 — 순번은 ROW_NUMBER, 이전 행은 LAG, 누적은 SUM() OVER, 문자열은 STRING_AGG 입니다.
  • 자료를 계산하는 일이면 집합으로, 작업을 반복하는 일이면 반복문으로 적습니다. 관리 스크립트나 행마다 프로시저를 부르는 일이 후자입니다.