MSSQL LAB
MSSQL 5.7 · 5부. 성능

대량 처리와 배치 삭제

큰 트랜잭션이 만드는 문제와 끊어 지우는 방법입니다.

예상 학습 시간 20분 난이도 고급
준비

지워도 되는 사본을 만듭니다

이 단원은 표를 통째로 지우고 다시 만듭니다. Board.POSTS_BIG 은 5.8 에서도 사용하므로 사본을 두고 실험합니다.

SQL
SELECT num, board_num, user_num, title, hit_count, reg_date
INTO Board.T_PURGE FROM Board.POSTS_BIG;

ALTER TABLE Board.T_PURGE ADD CONSTRAINT PK_T_PURGE PRIMARY KEY CLUSTERED (num);
CREATE INDEX IX_PURGE_REG ON Board.T_PURGE (reg_date);
GO

SELECT COUNT(*) FROM Board.T_PURGE;                                   -- 500,000
SELECT COUNT(*) FROM Board.T_PURGE WHERE reg_date < '2026-07-01';   -- 260,639

SELECT … INTO표를 만들면서 채웁니다. 미리 만들어 두지 않아도 되고, 대량으로 넣을 때 INSERT 보다 로그를 적게 씁니다. 대신 인덱스와 제약 조건은 따라오지 않으므로 뒤에 따로 붙입니다.

실측

한 문장으로 26만 행을 지웁니다

SQL
CHECKPOINT;   -- 로그 사용량을 재기 좋게 정리해 둡니다
GO

DECLARE @b decimal(18,2), @a decimal(18,2), @t datetime2(7), @r int;
SELECT @b = used_log_space_in_bytes/1024.0/1024 FROM sys.dm_db_log_space_usage;
SET @t = SYSDATETIME();

DELETE FROM Board.T_PURGE WHERE reg_date < '2026-07-01';

SET @r = @@ROWCOUNT;
SELECT @a = used_log_space_in_bytes/1024.0/1024 FROM sys.dm_db_log_space_usage;
PRINT CONCAT(@r, ' 행, ', DATEDIFF(millisecond, @t, SYSDATETIME()), ' ms, 로그 ',
             CAST(@a - @b AS decimal(9,1)), ' MB');
결과
260639 행, 1266 ms, 로그 66.7 MB
1.3초에 끝났습니다. 빠릅니다.

그런데 이 1.3초 동안 표에 아무도 접근하지 못합니다. 지우지 않는 행조차 그렇습니다. 다른 창에서 확인해 봅니다.

왼쪽 창 — 지우는 중
BEGIN TRAN;
DELETE FROM Board.T_PURGE WHERE reg_date >= '2026-07-01';
WAITFOR DELAY '00:00:10';   -- 관찰할 시간을 법니다
ROLLBACK;
오른쪽 창 — 관찰
SELECT l.resource_type AS 자원, l.request_mode AS 방식, COUNT(*) AS 개수
FROM sys.dm_tran_locks l
WHERE l.resource_database_id = DB_ID() AND l.request_session_id <> @@SPID
GROUP BY l.resource_type, l.request_mode;

-- 지우지 않는 행 하나를 읽어 봅니다.
SET LOCK_TIMEOUT 3000;
SELECT COUNT(*) FROM Board.T_PURGE WHERE num = 1;
잠금
자원방식개수
OBJECTX16
DATABASES1
읽어 보면
메시지 1222 - Lock request time out period exceeded. 3,003 밀리초를 기다리다 끝났습니다.
행 잠금이 아니라 OBJECT 에 X 가 걸려 있습니다. 표 전체가 잠긴 것입니다.

잠금 에스컬레이션입니다. 한 문장이 잠그는 행이 많아지면 엔진이 행 잠금 수만 개를 유지하는 대신 표 하나를 잠급니다. 메모리를 아끼는 판단이지만, 그 표를 쓰는 모든 사람이 멈춥니다.

5.5 에서 본 blocking_session_id 로 보면 이때 막힌 세션이 줄줄이 달립니다. 야간에 도는 정리 작업이 낮 시간까지 이어지면 서비스가 통째로 멈추는 것이 이 모양입니다.

배치

나누어 지웁니다

SQL
DECLARE @n int = 1, @total int = 0;

WHILE @n > 0
BEGIN
    DELETE TOP (4000) FROM Board.T_PURGE WHERE reg_date < '2026-07-01';
    SET @n = @@ROWCOUNT;
    SET @total += @n;
    -- 여기서 트랜잭션이 끝납니다. 잠금이 풀립니다.
END

PRINT CONCAT(@total, ' 행을 지웠습니다.');
배치 중에 잠금을 보면
자원방식개수
KEYX8,000
PAGEIX42
OBJECTIX1
그 사이에 다른 행을 읽으면
읽었습니다. 0 밀리초
OBJECT 에 걸린 것이 X 가 아니라 IX 입니다. 표는 잠기지 않았습니다.

이것이 배치로 나누는 유일한 까닭입니다. 그 밖의 숫자는 오히려 나빠집니다.

비교
-- 같은 26만 639행을 지운 결과입니다.
결과
방법걸린 시간로그표 잠금
한 문장1,266 밀리초66.7 MB걸립니다(X)
5,000행씩 54번2,870 밀리초72.0 MB걸리지 않습니다
배치가 2.3배 느리고 로그도 더 씁니다. 그래도 배치를 씁니다.

총 시간과 총 로그량은 배치가 더 나쁩니다. 문장을 54번 나누어 실행하니 그만큼 일이 늘어납니다. 얻는 것은 "그 시간을 독차지하지 않는 것" 하나입니다. 그리고 실제 운영에서는 대개 그 하나가 전부입니다.

로그가 복구 모델에 따라 다르게 움직입니다. 위 실습은 SIMPLE 모델이라 커밋될 때마다 로그를 재사용할 수 있습니다. FULL 모델이라면 로그 백업을 받기 전까지 지워지지 않으므로 한 문장으로 지우면 로그 파일이 그만큼 커집니다. 배치로 나누고 사이사이 로그 백업을 받는 것이 그래서 필요합니다(6.2).

배치 크기는 5,000 아래로

배치 크기를 바꿔 잠금을 본 결과
배치 크기잠금다른 행 읽기
4,000KEY X 8,000 · OBJECT IX됩니다
6,000OBJECT X막힙니다
임계는 약 5,000행입니다. 넘기면 배치로 나눈 뜻이 사라집니다.

한 문장이 5,000행쯤을 잠그면 엔진이 표 잠금으로 올립니다. 그래서 배치 크기를 5,000 으로 잡으면 매 배치가 승격 경계에 걸립니다. 2,000~4,000 이 무난합니다.

KEY X 가 8,000개인 것은 인덱스가 둘이기 때문입니다. 기본 키와 IX_PURGE_REG 에서 각각 4,000행씩 잠깁니다. 인덱스가 많을수록 잠기는 것도 늘어납니다(5.2).

전부

다 지울 때는 TRUNCATE

조건 없이 전부 지운다면 DELETE 를 쓸 까닭이 없습니다.

SQL
DELETE FROM Board.T_PURGE;
TRUNCATE TABLE Board.T_PURGE;
결과 — 같은 50만 행
방법걸린 시간로그
DELETE384 밀리초74.1 MB
TRUNCATE0 밀리초0.0 MB
TRUNCATE 는 행을 하나씩 지우지 않고 페이지 할당만 해제합니다.
DELETETRUNCATE
조건붙일 수 있습니다붙이지 못합니다
로그행마다 남깁니다페이지 해제만 남깁니다
IDENTITY이어집니다처음으로 돌아갑니다
트리거돕니다돌지 않습니다(4.7)
외래 키가 걸려 있으면됩니다되지 않습니다
되돌리기됩니다됩니다(트랜잭션 안이면)

TRUNCATE 도 롤백됩니다. 되돌릴 수 없다고 잘못 알려져 있는데, 트랜잭션 안에서 실행했다면 ROLLBACK 으로 돌아옵니다. 다만 다른 참조 표를 지운 표가 있으면 외래 키 때문에 아예 실행되지 않습니다.

거의 다 지울 때는 뒤집습니다

50만 행에서 49만 행을 지운다면 남길 1만 행을 옮기고 표를 비우는 편이 훨씬 쌉니다.

SQL
BEGIN TRAN;

-- 남길 것만 옮깁니다.
SELECT * INTO Board.T_PURGE_KEEP
FROM Board.T_PURGE WHERE reg_date >= '2026-12-01';

TRUNCATE TABLE Board.T_PURGE;

INSERT INTO Board.T_PURGE SELECT * FROM Board.T_PURGE_KEEP;
DROP TABLE Board.T_PURGE_KEEP;

COMMIT;

다만 그동안 표가 통째로 잠깁니다. 서비스를 세울 수 있는 시간에만 하십시오. 세울 수 없다면 배치로 나누어 지우는 수밖에 없습니다.

요령

배치 작업을 적을 때

SQL
CREATE OR ALTER PROCEDURE Board.P_PURGE_OLD
    @before   datetime2(0),
    @batch    int = 3000,
    @max_loop int = 1000
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @n int = 1, @total int = 0, @loop int = 0;

    WHILE @n > 0 AND @loop < @max_loop
    BEGIN
        DELETE TOP (@batch) FROM Board.T_PURGE WHERE reg_date < @before;
        SET @n = @@ROWCOUNT;
        SET @total += @n;
        SET @loop += 1;

        -- 다른 일이 끼어들 틈을 줍니다.
        IF @n > 0 WAITFOR DELAY '00:00:00.100';
    END

    SELECT @total AS 지운행, @loop AS 돈횟수,
           CASE WHEN @loop >= @max_loop THEN N'아직 남았습니다'
                ELSE N'끝났습니다' END AS 상태;
END
적어 둔 것까닭
트랜잭션으로 감싸지 않았습니다감싸면 배치로 나눈 뜻이 사라집니다. 각 DELETE 가 스스로 커밋됩니다
@max_loop 로 끊습니다조건이 잘못되어 끝나지 않을 때 밤새 도는 것을 막습니다
WAITFOR 0.1초배치 사이에 다른 일이 들어올 틈을 줍니다
배치 3,000에스컬레이션 임계(5,000) 아래입니다
진행 상황을 돌려줍니다남았는지 끝났는지 부르는 쪽이 알아야 합니다

조건에 사용하는 열에 인덱스가 있어야 합니다. 배치마다 조건에 맞는 행을 처음부터 다시 찾기 때문입니다. 인덱스가 없으면 표를 100번 훑는 일이 됩니다. 지우기 전에 SET STATISTICS IO ON 으로 한 배치가 몇 장을 읽는지 먼저 확인하십시오(5.1).

연습

직접 해보기

1. 조회수를 한 번에 반영합니다 난이도 하

5.5 에서 조회수를 글마다 바로 올리지 말고 모았다가 반영하자고 했습니다. 모아 둔 표를 읽어 한 문장으로 반영하는 프로시저를 만들어 보세요. 반영한 것은 지웁니다.

-- 모아 두는 표입니다. CREATE TABLE Board.HIT_QUEUE ( num int IDENTITY PRIMARY KEY, post_num int NOT NULL, reg_date datetime2(0) NOT NULL CONSTRAINT DF_HIT_QUEUE_reg DEFAULT SYSDATETIME() ); CREATE INDEX IX_HIT_QUEUE_post ON Board.HIT_QUEUE (post_num); GO CREATE OR ALTER PROCEDURE Board.P_HIT_FLUSH AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 4.5 DECLARE @moved TABLE (post_num int, cnt int); BEGIN TRAN; -- 지우면서 무엇을 지웠는지 받아 둡니다(2.9 의 OUTPUT). DELETE FROM Board.HIT_QUEUE OUTPUT deleted.post_num, 1 INTO @moved (post_num, cnt); UPDATE p SET hit_count = p.hit_count + m.cnt FROM Board.POSTS p JOIN (SELECT post_num, SUM(cnt) AS cnt FROM @moved GROUP BY post_num) m ON m.post_num = p.num; COMMIT; SELECT COUNT(*) AS 반영건수 FROM @moved; END GO -- 한 문장 안에서 지우고 받았으므로 그 사이에 들어온 것이 빠지지 않습니다. -- 반영하는 동안에도 화면은 계속 HIT_QUEUE 에 넣기만 하면 됩니다. -- 몇 분마다 SQL Server 에이전트 작업으로 부르면 됩니다(6.6). -- 줄이 아주 길어지면 DELETE TOP (n) 으로 나누십시오. DROP PROCEDURE Board.P_HIT_FLUSH; DROP TABLE Board.HIT_QUEUE;
2. 정리 작업이 서비스를 멈춥니다 난이도 중

로그 표에서 석 달 지난 것을 매일 새벽에 지웁니다. 어느 날부터 정리가 도는 동안 화면이 멈춘다는 신고가 들어옵니다. 문장은 아래 하나입니다. 무엇을 확인하고 어떻게 고치겠습니까.

SQL
DELETE FROM Board.T_PURGE
WHERE reg_date < DATEADD(month, -3, GETDATE());
어느 날부터 멈추기 시작했다는 것이 실마리입니다. 무엇이 달라졌겠습니까.
-- 1. 몇 건을 지우고 있는지 봅니다. SELECT COUNT(*) FROM Board.T_PURGE WHERE reg_date < DATEADD(month, -3, GETDATE()); -- 표가 커지면서 하루치가 5,000행을 넘어선 것입니다. -- 그때부터 잠금이 표 전체로 승격됩니다. -- "어느 날부터" 라는 말이 이 경계를 가리킵니다. -- 2. 잠금을 확인합니다. 도는 동안 다른 창에서 봅니다. SELECT resource_type, request_mode, COUNT(*) FROM sys.dm_tran_locks WHERE resource_database_id = DB_ID() GROUP BY resource_type, request_mode; -- OBJECT X 가 보이면 확정입니다. -- 3. 배치로 나눕니다. DECLARE @n int = 1, @loop int = 0; WHILE @n > 0 AND @loop < 1000 BEGIN DELETE TOP (3000) FROM Board.T_PURGE WHERE reg_date < DATEADD(month, -3, GETDATE()); SET @n = @@ROWCOUNT; SET @loop += 1; IF @n > 0 WAITFOR DELAY '00:00:00.100'; END -- 4. 조건 열에 인덱스가 있는지 봅니다. -- 없으면 배치마다 표를 훑어 오히려 더 오래 걸립니다. CREATE INDEX IX_PURGE_REG ON Board.T_PURGE (reg_date); -- 함께 보아 둘 것 -- GETDATE() 를 반복문 안에서 부르면 배치마다 기준이 조금씩 움직입니다. -- 변수에 한 번 담아 두는 편이 낫습니다. -- DECLARE @before datetime2(0) = DATEADD(month, -3, GETDATE()); -- 로그가 FULL 모델이라면 배치 사이에 로그 백업이 돌아야 -- 로그 파일이 계속 커지지 않습니다(6.2). -- 지우는 대신 옮겨 두어야 하는 자료라면 -- 파티션 전환이 훨씬 쌉니다. 다만 Enterprise 기능입니다.

실습에서 만든 사본을 지웁니다.
DROP TABLE Board.T_PURGE;
Board.POSTS_BIG 은 5.8 에서 사용하므로 그대로 둡니다.

요약
  • 한 문장으로 26만 행을 지우면 1,266밀리초에 끝나지만 그동안 표 전체가 잠깁니다(OBJECT X). 지우지 않는 행조차 읽지 못합니다.
  • 배치로 나누면 더 느리고(2,870밀리초) 로그도 더 씁니다(72.0MB 대 66.7MB). 그런데도 나누는 까닭은 표를 잠그지 않기 때문입니다(OBJECT IX).
  • 잠금 에스컬레이션 임계는 약 5,000행입니다. 4,000은 행 잠금으로 남고 6,000은 표 잠금으로 승격됩니다. 배치는 2,000~4,000 이 무난합니다.
  • 배치 작업은 트랜잭션으로 감싸지 마십시오. 감싸면 나눈 뜻이 사라집니다.
  • 반복문에는 최대 횟수를 두고, 배치 사이에 짧게 쉬어 다른 일이 들어올 틈을 줍니다.
  • 조건 열에 인덱스가 있어야 합니다. 배치마다 조건에 맞는 행을 처음부터 다시 찾기 때문입니다.
  • 조건 없이 전부 지운다면 TRUNCATE 입니다. 50만 행이 384밀리초·74.1MB 에서 0밀리초·0MB 가 됩니다. 트랜잭션 안이면 롤백도 됩니다.
  • 거의 다 지울 때는 남길 것을 옮기고 표를 비우는 편이 쌉니다. 다만 그동안 표가 통째로 잠깁니다.
  • FULL 복구 모델이라면 로그 백업을 받기 전까지 로그가 지워지지 않습니다. 배치로 나누고 사이사이 백업을 받아야 합니다(6.2).