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 건입니다. 어느 행인지도 정해져 있지 않습니다.

트리거는 바뀐 행마다 도는 것이 아니라 문장마다 한 번 돕니다. inserteddeleted 에는 바뀐 행이 모두 들어 있습니다. 그것을 변수 하나에 담으면 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 로 끌 수도 있습니다.
  • 제약 · 계산 열 · 프로시저로 되는 일은 그것으로 하십시오. 트리거는 문장을 읽는 사람에게 보이지 않습니다.