대량 처리와 배치 삭제
큰 트랜잭션이 만드는 문제와 끊어 지우는 방법입니다.
지워도 되는 사본을 만듭니다
이 단원은 표를 통째로 지우고 다시 만듭니다.
Board.POSTS_BIG 은 5.8 에서도 사용하므로
사본을 두고 실험합니다.
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만 행을 지웁니다
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');
그런데 이 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;
| 자원 | 방식 | 개수 |
|---|---|---|
| OBJECT | X | 16 |
| DATABASE | S | 1 |
잠금 에스컬레이션입니다. 한 문장이 잠그는 행이 많아지면 엔진이 행 잠금 수만 개를 유지하는 대신 표 하나를 잠급니다. 메모리를 아끼는 판단이지만, 그 표를 쓰는 모든 사람이 멈춥니다.
5.5 에서 본 blocking_session_id 로 보면 이때
막힌 세션이 줄줄이 달립니다. 야간에 도는 정리 작업이 낮 시간까지
이어지면 서비스가 통째로 멈추는 것이 이 모양입니다.
나누어 지웁니다
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, ' 행을 지웠습니다.');
| 자원 | 방식 | 개수 |
|---|---|---|
| KEY | X | 8,000 |
| PAGE | IX | 42 |
| OBJECT | IX | 1 |
이것이 배치로 나누는 유일한 까닭입니다. 그 밖의 숫자는 오히려 나빠집니다.
-- 같은 26만 639행을 지운 결과입니다.
| 방법 | 걸린 시간 | 로그 | 표 잠금 |
|---|---|---|---|
| 한 문장 | 1,266 밀리초 | 66.7 MB | 걸립니다(X) |
| 5,000행씩 54번 | 2,870 밀리초 | 72.0 MB | 걸리지 않습니다 |
총 시간과 총 로그량은 배치가 더 나쁩니다. 문장을 54번 나누어 실행하니 그만큼 일이 늘어납니다. 얻는 것은 "그 시간을 독차지하지 않는 것" 하나입니다. 그리고 실제 운영에서는 대개 그 하나가 전부입니다.
로그가 복구 모델에 따라 다르게 움직입니다. 위 실습은 SIMPLE 모델이라 커밋될 때마다 로그를 재사용할 수 있습니다. FULL 모델이라면 로그 백업을 받기 전까지 지워지지 않으므로 한 문장으로 지우면 로그 파일이 그만큼 커집니다. 배치로 나누고 사이사이 로그 백업을 받는 것이 그래서 필요합니다(6.2).
배치 크기는 5,000 아래로
| 배치 크기 | 잠금 | 다른 행 읽기 |
|---|---|---|
| 4,000 | KEY X 8,000 · OBJECT IX | 됩니다 |
| 6,000 | OBJECT X | 막힙니다 |
한 문장이 5,000행쯤을 잠그면 엔진이 표 잠금으로 올립니다. 그래서 배치 크기를 5,000 으로 잡으면 매 배치가 승격 경계에 걸립니다. 2,000~4,000 이 무난합니다.
KEY X 가 8,000개인 것은 인덱스가 둘이기
때문입니다. 기본 키와 IX_PURGE_REG 에서
각각 4,000행씩 잠깁니다. 인덱스가 많을수록 잠기는 것도 늘어납니다(5.2).
다 지울 때는 TRUNCATE
조건 없이 전부 지운다면 DELETE 를 쓸 까닭이
없습니다.
DELETE FROM Board.T_PURGE; TRUNCATE TABLE Board.T_PURGE;
| 방법 | 걸린 시간 | 로그 |
|---|---|---|
| DELETE | 384 밀리초 | 74.1 MB |
| TRUNCATE | 0 밀리초 | 0.0 MB |
| DELETE | TRUNCATE | |
|---|---|---|
| 조건 | 붙일 수 있습니다 | 붙이지 못합니다 |
| 로그 | 행마다 남깁니다 | 페이지 해제만 남깁니다 |
| IDENTITY | 이어집니다 | 처음으로 돌아갑니다 |
| 트리거 | 돕니다 | 돌지 않습니다(4.7) |
| 외래 키가 걸려 있으면 | 됩니다 | 되지 않습니다 |
| 되돌리기 | 됩니다 | 됩니다(트랜잭션 안이면) |
TRUNCATE 도 롤백됩니다. 되돌릴
수 없다고 잘못 알려져 있는데, 트랜잭션 안에서 실행했다면
ROLLBACK 으로 돌아옵니다. 다만
다른 참조 표를 지운 표가 있으면 외래 키 때문에 아예 실행되지
않습니다.
거의 다 지울 때는 뒤집습니다
50만 행에서 49만 행을 지운다면 남길 1만 행을 옮기고 표를 비우는 편이 훨씬 쌉니다.
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;
다만 그동안 표가 통째로 잠깁니다. 서비스를 세울 수 있는 시간에만 하십시오. 세울 수 없다면 배치로 나누어 지우는 수밖에 없습니다.
배치 작업을 적을 때
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).
직접 해보기
5.5 에서 조회수를 글마다 바로 올리지 말고 모았다가 반영하자고 했습니다. 모아 둔 표를 읽어 한 문장으로 반영하는 프로시저를 만들어 보세요. 반영한 것은 지웁니다.
로그 표에서 석 달 지난 것을 매일 새벽에 지웁니다. 어느 날부터 정리가 도는 동안 화면이 멈춘다는 신고가 들어옵니다. 문장은 아래 하나입니다. 무엇을 확인하고 어떻게 고치겠습니까.
DELETE FROM Board.T_PURGE WHERE reg_date < DATEADD(month, -3, GETDATE());
실습에서 만든 사본을 지웁니다.
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).