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

트리거

AFTER 와 INSTEAD OF, 그리고 사용하기 전에 따져 볼 것들입니다. 트리거는 행마다 돌지 않습니다.

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

표가 바뀔 때 저절로 도는 코드

프로시저는 불러야 돕니다. 트리거는 표가 바뀌면 저절로 돕니다. 누가 어떤 경로로 고치든 실행되므로, 3.4 의 제약처럼 빠져나갈 길이 없습니다.

트리거 안에서는 무엇이 바뀌었는지를 두 개의 가상 표로 봅니다.

문장inserteddeleted
INSERT넣은 행비어 있음
UPDATE고친 뒤의 행고치기 전의 행
DELETE비어 있음지운 행

글의 제목이 바뀔 때마다 이력을 남기는 표를 두고, 트리거로 채워 봅니다.

SQL
CREATE TABLE Board.POST_HISTORY (
    num       int IDENTITY(1,1) NOT NULL,
    post_num  int           NOT NULL,
    old_title nvarchar(200) NULL,
    new_title nvarchar(200) NULL,
    chg_date  datetime2(0)  NOT NULL
        CONSTRAINT DF_POST_HISTORY_chg DEFAULT SYSDATETIME(),
    CONSTRAINT PK_POST_HISTORY PRIMARY KEY CLUSTERED (num));
함정

트리거는 행마다 돌지 않습니다

가장 흔한 실수부터 봅니다. 행이 하나뿐이라고 여기고 적은 트리거입니다.

SQL
CREATE OR ALTER TRIGGER Board.TR_POSTS_TITLE_BAD
ON Board.POSTS
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF NOT UPDATE(title) RETURN;

    DECLARE @post_num int, @old nvarchar(200), @new nvarchar(200);

    SELECT @post_num = num, @old = title FROM deleted;   -- 한 행이라고 가정합니다
    SELECT @new = title FROM inserted;

    INSERT INTO Board.POST_HISTORY (post_num, old_title, new_title)
    VALUES (@post_num, @old, @new);
END
GO
-- (가) 한 행을 고칩니다.
UPDATE Board.POSTS SET title = title + N' (고침)' WHERE num = 250;

-- (나) 공지사항 35건을 한 번에 고칩니다.
UPDATE Board.POSTS SET title = title + N' (고침)' WHERE board_num = 1;
결과
고친 문장고친 행남은 기록
(가) 한 행11
(나) 35 행351
35 행을 고쳤는데 기록은 1 건입니다. 어느 행인지도 정해져 있지 않습니다.

트리거는 바뀐 행마다 도는 것이 아니라 문장마다 한 번 돕니다. inserted 와 deleted 에는 바뀐 행이 모두 들어 있습니다. 그것을 변수 하나에 담으면 4.1 에서 본 대로 마지막 값 하나만 남습니다.

오류가 나지 않는 것이 가장 나쁩니다. 화면에서 한 건씩 고치는 동안에는 잘 돌다가, 관리자가 일괄 수정을 한 날부터 이력이 새기 시작합니다.

집합으로 적습니다

SQL
CREATE OR ALTER TRIGGER Board.TR_POSTS_TITLE
ON Board.POSTS
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF NOT UPDATE(title) RETURN;

    -- 두 가상 표를 키로 이어 한 문장으로 넣습니다.
    INSERT INTO Board.POST_HISTORY (post_num, old_title, new_title)
    SELECT i.num, d.title, i.title
    FROM inserted i
        JOIN deleted d ON d.num = i.num
    WHERE ISNULL(i.title, N'') <> ISNULL(d.title, N'');
END
결과 — 35 행을 다시 고치면
post_num이전이후
10제약 조건 질문드립니다제약 조건 질문드립니다 (고침)
20인덱스 정리해 봤습니다인덱스 정리해 봤습니다 (고침)
30제약 조건 정리해 봤습니다제약 조건 정리해 봤습니다 (고침)
기록이 35 건입니다. 앞 3 건만 보였습니다.

거르는 조건 둘을 더 두었습니다. UPDATE(title) 은 그 열이 SET 목록에 있었는지를 봅니다 — 조회수만 고친 문장에서는 트리거가 곧바로 나갑니다. 그리고 WHERE 로 값이 실제로 달라진 행만 남깁니다.

고친 문장남은 기록
제목을 바꿈 (35 행)35
조회수만 바꿈0
제목을 같은 값으로 덮어씀0

UPDATE(title) 은 값이 바뀌었는지가 아니라 목록에 있었는지를 봅니다. 그래서 같은 값으로 덮어써도 참입니다. 값 비교는 따로 해야 합니다.

트랜잭션

트리거 안은 이미 트랜잭션 안입니다

BEGIN TRAN 을 적지 않고 UPDATE 만 해도, 그 트리거 안에서 @@TRANCOUNT 는 1 입니다. 4.6 에서 본 자동 커밋 트랜잭션 안에 있기 때문입니다.

SQL
CREATE OR ALTER TRIGGER Board.TR_POSTS_GUARD
ON Board.POSTS
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @@TRANCOUNT AS 트랜잭션수, @@NESTLEVEL AS 중첩수준;

    IF EXISTS (SELECT 1 FROM inserted WHERE hit_count < 0)
        THROW 50003, N'조회수는 음수가 될 수 없습니다', 1;
END
GO
UPDATE Board.POSTS SET hit_count = hit_count WHERE num = 250;
결과
트랜잭션수중첩수준
11
BEGIN TRAN 을 적지 않았는데도 1 입니다.

그래서 트리거에서 THROW 하면 그 문장 전체가 되돌아갑니다. 3.4 의 제약과 같은 효과를, 제약으로는 적을 수 없는 규칙에 대해 낼 수 있습니다.

SQL
BEGIN TRAN;
BEGIN TRY
    UPDATE Board.POSTS SET hit_count = -1 WHERE num = 250;
END TRY
BEGIN CATCH
    SELECT ERROR_NUMBER() AS 번호, XACT_STATE() AS 상태;
END CATCH
IF XACT_STATE() <> 0 ROLLBACK;
SELECT num, hit_count FROM Board.POSTS WHERE num = 250;
결과
번호XACT_STATE()250 번 값
50003-1250 (원래대로)
트리거의 THROW 가 커밋할 수 없는 상태를 만들었습니다(4.5).

ROLLBACK 은 배치를 통째로 죽입니다

THROW 대신 ROLLBACK 을 적으면 결과가 다릅니다.

SQL
-- 트리거 안에서 ROLLBACK 을 적었을 때
BEGIN TRAN;
UPDATE Board.POSTS SET hit_count = -1 WHERE num = 250;
PRINT N'>> 이 줄이 실행됩니까?';
SELECT @@TRANCOUNT AS 남은트랜잭션;
결과
메시지 3609 The transaction ended in the trigger. The batch has been aborted.
PRINT 도 SELECT 도 실행되지 않았습니다. 배치가 그 자리에서 끝났습니다.

4.5 에서 본 것과 정반대입니다. 보통은 오류가 나도 다음 문장이 실행되는데, 트리거의 ROLLBACK 은 배치를 중단시킵니다. 부르는 쪽의 TRY...CATCH 도 소용없습니다.

트리거에서는 ROLLBACK 대신 THROW 를 사용하십시오. 같은 효과를 내면서 부르는 쪽이 잡아 처리할 수 있습니다. 오류 번호로 무엇이 막혔는지도 전할 수 있습니다.

INSTEAD OF

대신 실행합니다

AFTER 트리거는 문장이 끝난 뒤에 돕니다. INSTEAD OF 는 그 문장 대신 돕니다. 원래 문장은 실행되지 않고 트리거가 할 일을 정합니다.

3.7 에서 조인 뷰로 두 표를 함께 고치려다 막혔습니다.

SQL
UPDATE Board.V_POST_LIST SET title = N'x', nickname = N'y' WHERE num = 250;
오류
메시지 4405 View or function 'Board.V_POST_LIST' is not updatable because the modification affects multiple base tables.

INSTEAD OF 트리거를 달면 무엇을 어떻게 고칠지 직접 적을 수 있습니다.

SQL
CREATE OR ALTER TRIGGER Board.TR_V_POST_LIST_UPD
ON Board.V_POST_LIST
INSTEAD OF UPDATE
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE p SET title = i.title, hit_count = i.hit_count
    FROM Board.POSTS p JOIN inserted i ON i.num = p.num;

    UPDATE u SET nickname = i.nickname
    FROM Member.USERS u
        JOIN Board.POSTS p ON p.user_num = u.num
        JOIN inserted i ON i.num = p.num;
END
GO
UPDATE Board.V_POST_LIST SET title = N'제목을 바꿉니다', nickname = N'닉네임도'
WHERE num = 250;
결과
numtitlenickname
250제목을 바꿉니다닉네임도
두 표가 함께 바뀌었습니다. 회원 11 번의 닉네임도 실제로 바뀝니다.

이 예제는 되기 때문에 위험합니다. 글 하나의 제목을 고치려다 그 사람의 닉네임을 바꿔 버렸습니다. 다른 글에도 그 이름으로 나옵니다. 뷰가 여러 표를 잇고 있으면 무엇을 고치는 것인지 문장만 보고 알 수 없습니다 — INSTEAD OF 로 되게 만들기 전에 정말 그 뷰로 고쳐야 하는지 먼저 따지십시오. 3.7 의 결론은 여전히 유효합니다.

따져 볼 것

사용하기 전에

건너뛰는 길이 있습니다

SQL
-- DELETE 트리거를 달아 둔 표입니다.
DELETE FROM Board.T_TRG;        -- (가)
TRUNCATE TABLE Board.T_TRG;    -- (나)
결과
지운 방법남은 행트리거가 남긴 기록
(가) DELETE01건 (5행을 지웠다고)
(나) TRUNCATE00건
행은 똑같이 사라졌는데 트리거는 돌지 않았습니다.

TRUNCATE 는 트리거를 발동하지 않습니다. 대량 삽입(BULK INSERT)도 기본으로는 그렇습니다. 그리고 누구든 트리거를 끌 수 있습니다.

SQL
DISABLE TRIGGER Board.TR_T_TRG_DEL ON Board.T_TRG;
DELETE FROM Board.T_TRG;                 -- 기록이 남지 않습니다
ENABLE TRIGGER Board.TR_T_TRG_DEL ON Board.T_TRG;

-- 지금 무엇이 걸려 있고 꺼져 있는지 봅니다.
SELECT t.name AS 트리거, OBJECT_NAME(t.parent_id) AS 대상,
       t.is_instead_of_trigger AS INSTEAD_OF, t.is_disabled AS 꺼짐
FROM sys.triggers t WHERE t.is_ms_shipped = 0;
결과
트리거대상INSTEAD_OF꺼짐
TR_POSTS_TITLEPOSTS00
TR_V_POST_LIST_UPDV_POST_LIST10
감사 기록을 트리거에만 기대면 이 목록을 정기적으로 확인해야 합니다.

보이지 않습니다

UPDATE Board.POSTS SET title = … 이라는 문장만 읽어서는 이력 표에 무엇이 쌓이는지 알 수 없습니다. 자료가 이상한데 원인을 못 찾을 때, 트리거를 떠올리기까지 시간이 걸립니다.

트리거가 트리거를 부르면 더 어려워집니다. 이 데이터베이스의 설정을 확인해 둡니다.

설정값뜻
nested triggers1트리거가 다른 트리거를 발동합니다(최대 32단계)
RECURSIVE_TRIGGERS0자기 표를 고쳐도 자기를 다시 부르지 않습니다

언제 사용합니까

  • 감사 기록 — 누가 무엇을 언제 바꿨는지 남기는 일입니다. 어느 경로로 들어와도 남아야 하므로 트리거가 맞습니다. 이 단원의 POST_HISTORY 가 그 예입니다.
  • 제약으로 적을 수 없는 규칙 — 3.4 의 CHECK 는 같은 행 안만 봅니다. 다른 표를 봐야 하는 규칙은 트리거로 지킵니다.
  • 뷰를 통한 수정 — INSTEAD OF 가 유일한 방법입니다.

먼저 다른 것으로 되는지 보십시오. 값의 범위는 CHECK(3.4), 다른 열에서 이끌어 내는 값은 계산 열(3.6), 여러 문장을 묶는 절차는 프로시저(4.2)가 낫습니다. 셋 다 문장을 읽는 사람에게 보입니다. 트리거는 그것들로 안 될 때 씁니다.

연습

직접 해보기

1. 답글이 달린 글은 지울 수 없게 난이도 하

3.3 에서 답글이 달린 글은 외래 키 때문에 지울 수 없다는 것을 보았습니다. 그런데 오류 메시지가 547 FK_POSTS_PARENT 라서 화면에 그대로 보이기 어렵습니다. 트리거로 알아보기 쉬운 메시지를 내도록 해 보세요.

CREATE OR ALTER TRIGGER Board.TR_POSTS_DEL_GUARD ON Board.POSTS AFTER DELETE AS BEGIN SET NOCOUNT ON; -- 지운 글을 부모로 삼는 답글이 남아 있는지 봅니다. IF EXISTS (SELECT 1 FROM Board.POSTS p JOIN deleted d ON p.parent_num = d.num) THROW 50004, N'답글이 달린 글은 지울 수 없습니다', 1; END -- AFTER 인데도 막을 수 있는 것은 트리거가 같은 트랜잭션 안이기 때문입니다. -- THROW 가 그 문장 전체를 되돌립니다. -- 다만 이 경우에는 외래 키가 이미 막고 있어 트리거까지 오지 않습니다. -- 실제로는 3.3 의 547 이 먼저 납니다. -- 트리거로 메시지를 바꾸려면 INSTEAD OF DELETE 로 만들어 -- 검사를 먼저 하고 통과한 것만 지워야 합니다. CREATE OR ALTER TRIGGER Board.TR_POSTS_DEL_GUARD ON Board.POSTS INSTEAD OF DELETE AS BEGIN SET NOCOUNT ON; IF EXISTS (SELECT 1 FROM Board.POSTS p JOIN deleted d ON p.parent_num = d.num) THROW 50004, N'답글이 달린 글은 지울 수 없습니다', 1; DELETE p FROM Board.POSTS p JOIN deleted d ON d.num = p.num; END -- 순서가 뒤바뀌는 것이 핵심입니다. -- AFTER : 지우려 시도 → 외래 키가 547 → 트리거까지 오지 않음 -- INSTEAD OF : 트리거가 먼저 → 검사 → 통과한 것만 지움
2. 일괄 수정에서 새는 트리거 난이도 중

아래 트리거는 댓글이 지워질 때 이력에 남깁니다. 한 건씩 지울 때는 잘 돌았는데 관리자가 일괄 삭제를 한 뒤로 기록이 맞지 않습니다. 무엇이 잘못되었고 어떻게 고칩니까. 이력 표는 아래와 같이 두었다고 합시다.

SQL
CREATE TABLE Board.CMT_HISTORY (
    num      int IDENTITY(1,1) PRIMARY KEY,
    post_num int          NOT NULL,
    user_num int          NOT NULL,
    del_date datetime2(0) NOT NULL);
SQL
CREATE OR ALTER TRIGGER Board.TR_CMT_DEL
ON Board.COMMENTS
AFTER DELETE
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @post int, @user int;
    SELECT @post = post_num, @user = user_num FROM deleted;

    INSERT INTO Board.CMT_HISTORY (post_num, user_num, del_date)
    VALUES (@post, @user, SYSDATETIME());
END
deleted 에 행이 몇 개 들어 있습니까. 변수 하나에 담으면 몇 개가 남습니까.
-- 트리거는 문장마다 한 번 돕니다. deleted 에는 지운 행이 모두 들어 있는데 -- 변수 하나에 담으면 그중 하나만 남고 나머지는 사라집니다. -- 글 하나를 지우면 CASCADE 로 댓글이 여러 건 지워지므로(3.3) -- 그때부터 기록이 한 건씩만 남습니다. CREATE OR ALTER TRIGGER Board.TR_CMT_DEL ON Board.COMMENTS AFTER DELETE AS BEGIN SET NOCOUNT ON; INSERT INTO Board.CMT_HISTORY (post_num, user_num, del_date) SELECT post_num, user_num, SYSDATETIME() FROM deleted; -- 집합 그대로 넣습니다 END -- 확인하는 방법 — 4 번 글에는 댓글이 4 건 달려 있습니다. BEGIN TRAN; SELECT COUNT(*) AS 지울댓글 FROM Board.COMMENTS WHERE post_num = 4; DELETE FROM Board.POSTS WHERE num = 4; -- CASCADE 로 댓글도 지워집니다 SELECT COUNT(*) AS 남은기록 FROM Board.CMT_HISTORY; ROLLBACK; -- 지울댓글 남은기록 -- 고치기 전 4 1 ← 세 건이 새었습니다 -- 고친 뒤 4 4 -- 트리거를 만들 때마다 스스로 물어보십시오. -- "이 트리거는 1,000 행을 한 번에 고쳐도 맞습니까?"

이 단원에서 만든 것을 지웁니다.
DROP TRIGGER Board.TR_POSTS_TITLE, Board.TR_POSTS_GUARD;
DROP VIEW Board.V_POST_LIST;
DROP TABLE Board.POST_HISTORY, Board.CMT_HISTORY, Board.T_TRG, Board.T_TRG_LOG, Board.T_NOTRG;

요약
  • 트리거는 표가 바뀌면 저절로 돕니다. 바뀐 내용은 inserted · deleted 가상 표로 봅니다.
  • 행마다가 아니라 문장마다 한 번 돕니다. 35 행을 고쳐도 한 번이고, deleted 에는 35 행이 모두 들어 있습니다.
  • 변수 하나에 담지 마십시오. 오류 없이 기록이 한 건만 남습니다. INSERT … SELECT … FROM inserted 로 집합을 그대로 다룹니다.
  • UPDATE(열) 은 값이 바뀌었는지가 아니라 SET 목록에 있었는지를 봅니다. 값 비교는 따로 합니다.
  • 트리거 안은 이미 트랜잭션 안입니다. THROW 하면 문장 전체가 되돌아갑니다.
  • 트리거에서 ROLLBACK 하면 배치가 통째로 중단됩니다(3609). THROW 를 사용하십시오.
  • INSTEAD OF 는 원래 문장 대신 돕니다. 조인 뷰 수정(4405)을 푸는 유일한 방법이지만, 되게 만들기 전에 그래야 하는지 따지십시오.
  • TRUNCATE 는 트리거를 건너뜁니다. 누구든 DISABLE TRIGGER 로 끌 수도 있습니다.
  • 제약 · 계산 열 · 프로시저로 되는 일은 그것으로 하십시오. 트리거는 문장을 읽는 사람에게 보이지 않습니다.