커서와 집합 기반 사고
한 행씩 도는 코드를 집합을 다루는 문장으로 바꿉니다. 4.1 에서 미뤄 둔 것을 여기서 갚습니다.
결과를 한 행씩 읽는 장치
4.1 에서 반복문으로 507번 물어보는 코드를 보았습니다. 커서는 그것을 위한 전용 장치입니다. 결과 집합을 열어 두고 한 행씩 꺼냅니다.
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; -- 버립니다
| 공지 | 자유 | 질문 |
|---|---|---|
| 35 | 315 | 157 |
문법이 길다는 것보다 더 큰 문제가 있습니다. 얼마나 비싼지 재 봅니다.
5만 행에 4.8초와 0.07초
덩어리마다 순번을 매기는 일입니다. 3.9 의
sort_no 를 다시 매기는 작업이 이 모양입니다.
-- 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;
| 방식 | 밀리초 |
|---|---|
| 커서로 한 행씩 | 4,770~4,903 |
| 윈도 함수 한 문장 | 64~67 |
커서는 5만 번 왕복합니다. 행마다 꺼내고, 판단하고,
UPDATE 문장을 따로 실행합니다. 한 문장은 무엇을
원하는지만 적고 어떻게 처리할지는 엔진이 정합니다 — 정렬도
한 번, 갱신도 한 번입니다.
옵션을 붙이면 빨라집니다. 그래도 멉니다
기본 커서는 가장 무거운 조합으로 만들어집니다. 읽기만 할 것이라면 그렇게 말해 주어야 합니다.
DECLARE c1 CURSOR FOR … -- 기본 DECLARE c2 CURSOR LOCAL FAST_FORWARD FOR … -- 앞으로만, 읽기만
| 방식 | 밀리초 |
|---|---|
| 기본 커서 | 475~499 |
| LOCAL FAST_FORWARD | 189~199 |
| 한 문장(COUNT) | 1 |
기본 커서가 무거운 까닭은 속성을 보면 드러납니다.
SELECT name AS 커서, properties AS 속성, is_open AS 열림 FROM sys.dm_exec_cursors(@@SPID) WHERE name IS NOT NULL;
| 커서 | 속성 | 열림 |
|---|---|---|
| cur_leak | TSQL | Dynamic | Optimistic | Global (0) | 1 |
이 커서는 닫지 않고 빠져나온 것입니다.
Global 이라 배치가 끝나도 남아 있고, 연결이 끊길
때까지 그대로입니다. LOCAL 을 붙이면 배치가 끝날
때 저절로 사라집니다. 커서를 만든다면
LOCAL FAST_FORWARD 를 기본으로 삼으십시오.
커서로 하던 일을 문장으로
커서를 쓰게 되는 자리는 몇 가지로 정해져 있습니다. 대부분 2.7 의 윈도 함수로 풀립니다.
순번 매기기 → ROW_NUMBER
-- 앞에서 본 것입니다. 덩어리마다 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 는 그것을 문장 안에서
합니다.
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;
| num | hit_count | 앞글 | 차이 |
|---|---|---|---|
| 10 | 70 | NULL | NULL |
| 20 | 140 | 70 | 70 |
| 30 | 210 | 140 | 70 |
| 40 | 280 | 210 | 70 |
누적 합계 → SUM OVER
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;
| num | hit_count | 누적 |
|---|---|---|
| 10 | 70 | 70 |
| 20 | 140 | 210 |
| 30 | 210 | 420 |
| 40 | 280 | 700 |
문자열 이어 붙이기 → STRING_AGG
-- 커서로 하면 변수에 계속 이어 붙여야 합니다. 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 · LEAD | 2.7 |
| 누적 합계 | SUM() OVER | 2.7 |
| 문자열 이어 붙이기 | STRING_AGG | 2.2 |
| 행마다 다른 값 넣기 | CASE | 2.8 |
| 계층 따라 내려가기 | 재귀 CTE | 2.5 |
| 있으면 고치고 없으면 넣기 | MERGE 또는 두 문장 | 1.8 |
커서를 만들고 싶어지면 이 표를 먼저 보십시오. 대부분 이미 배운 문법으로 풀립니다. 새로 배울 것이 있어서 커서를 쓰는 것이 아니라, 절차로 생각하는 습관 때문에 커서를 쓰게 됩니다.
커서가 맞는 자리도 있습니다
- 행마다 프로시저를 불러야 할 때. 표마다 인덱스를 다시 만들거나, 데이터베이스마다 같은 작업을 하는 관리 스크립트가 그렇습니다. 집합으로 적을 수 없는 일입니다.
- 행마다 실패해도 나머지를 계속해야 할 때. 한 문장은 전부 되거나 전부 안 됩니다(4.6). 1,000건 중 3건이 잘못되었어도 997건은 넣어야 한다면 한 건씩 다루어야 합니다.
- 큰 작업을 끊어서 할 때. 500만 행을 한 문장으로 지우면
로그가 가득 차고 잠금이 오래 유지됩니다. 이때는 끊어서 도는데, 커서보다는
WHILE과TOP을 쓰는 편이 낫습니다. 5.7 에서 다룹니다.
공통점이 보입니다 — 셋 다 "자료를 계산하는 일" 이 아니라 "작업을 반복하는 일" 입니다. 자료를 다루는 일이라면 집합으로 적을 수 있고, 작업을 반복하는 일이라면 반복문이 맞습니다.
직접 해보기
아래 코드는 게시판별 조회수 합계를 냅니다. 한 문장으로 바꿔 보세요.
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;
4.5 에서 보았듯 답글 넣기가 반쪽만 끝나면
sort_no 에 빈 자리가 생깁니다. 덩어리마다
0 부터 빈틈없이 다시 매기는 문장을 적어 보세요. 커서는
사용하지 않습니다.
이 단원에서 만든 표를 지웁니다.
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입니다. - 자료를 계산하는 일이면 집합으로, 작업을 반복하는 일이면 반복문으로 적습니다. 관리 스크립트나 행마다 프로시저를 부르는 일이 후자입니다.