Oracle PL/SQL - 트리거

김소희·2025년 11월 7일

TRIGGER (트리거)

트리거는 CPU와 메모리를 많이 필요로 하는 아주 비싼 자원이다. 강력하지만 위험한 도구다. 잘 쓰면 업무를 자동화하고 데이터 일관성을 유지하는 약이 되지만, 잘못 쓰면 성능을 저하시키고 유지보수를 어렵게 만드는 독이 된다.

실무에서는 트리거를 최소한으로만 사용하고, 꼭 필요한 경우에만 신중하게 적용해야 한다. 특히 대용량 데이터를 처리하는 시스템에서는 트리거보다 배치 작업이나 프로시저를 사용하는 것이 안전하다.

트리거를 만들 때는 항상 "이게 정말 트리거로 해야 하나?" 를 먼저 고민해야 한다. 애플리케이션 레벨에서 처리할 수 있다면 그쪽이 더 나은 선택일 수 있다.

트리거의 개념

트리거(trigger)의 사전적인 의미는 방아쇠나 (방아쇠를) 쏘다, 발사하다, 행위를 취하면 동작을 발생시킨다, (사건을) 유발시키다라는 의미가 있다.

PL/SQL에서의 트리거 역시 방아쇠가 당겨지면 자동으로 총알이 발사되듯이 어떠한 이벤트가 발생하면 그에 따라 다른 작업이 자동으로 처리되는 것을 의미한다.

트리거란 특정 테이블의 데이터에 (INSERT, UPDATE, DELETE) 이벤트가 발생하면 자동으로 수행되는 저장 프로시저라고 할 수 있다.
앞서 배운 저장 프로시저는 필요할 때마다 사용자가 직접 EXECUTE 명령어로 호출해야 했다.
하지만 트리거는 이와 달리 테이블의 데이터가 INSERT, UPDATE, DELETE 문에 의해 변경되어질 때 자동으로 수행되므로 이 기능을 이용하여 여러 가지 작업을 할 수 있다.
자동 수행 되므로 사용자가 직접 실행시킬 수 없다.

실무 예시: 입고와 재고 관리

상품이 입고(INSERT)되면 재고도 INSERT 되야한다. 이런 경우에
입고 테이블에 데이터를 INSERT하면 트리거가 자동으로 재고 테이블도 업데이트하는 방식으로 사용한다.
하지만 이렇게 여러 테이블이 연쇄적으로 변경되면 트랜잭션이 길어지고 락(lock)이 발생할 위험이 있다.
락이 걸리면 롤백이 되는 동안 테이블에 접근을 못하게 되므로 성능이 저하된다.

트리거의 종류

BEFORE 트리거: 테이블에서 DML 실행되기 전에 트리거가 동작한다. 데이터 검증, 값 변경, 제약 조건 체크 등에 사용한다.

AFTER 트리거: 테이블에서 DML 실행 후에 트리거가 동작한다. 로그 기록, 다른 테이블 업데이트, 알림 발송 등에 사용한다.

트리거 기본 문법

CREATE [OR REPLACE] TRIGGER trigger_name
{BEFORE | AFTER} triggering_event [OF column1, ...] ON table_name
[FOR EACH ROW [WHEN trigger_condition]]
trigger_body;

trigger_name: TRIGGER의 식별자

BEFORE | AFTER: DML 문장이 실행되기 전에 TRIGGER를 실행할 것인지 실행된 후에 TRIGGER를 실행할 것인지를 정의

triggering_event: TRIGGER를 실행하는 DML(INSERT, UPDATE, DELETE)문을 기술한다

OF column: TRIGGER가 실행되는 테이블에서 COLUMN 명을 기술한다

table_name: TRIGGER가 실행되는 테이블 이름

FOR EACH ROW: 이 옵션을 사용하면 행 레벨 트리거가 되어 triggering 문장에 의해 영향받은 행에 대해 각각 한번씩 실행하고, 사용하지 않으면 문장 레벨 트리거가 되어 DML 문장 당 한번만 실행된다

OLD와 NEW 가상 테이블

행 레벨 TRIGGER에서만 사용할 수 있는 예약어로 트리거 내에서 현재 처리되고 있는 행을 액세스할 수 있다. 즉 두 개의 의사 레코드를 통하여 이 작업을 수행할 수 있다.

:OLD는 INSERT 문에 의해 정의되지 않고, :NEW는 DELETE에 대해 정의되지 않는다. 그러나 UPDATE는 :OLD:NEW를 모두 정의한다.

트리거 이벤트:OLD:NEW
INSERT모든 필드는 NULL삽입된 새로운 값
UPDATE갱신 전의 원래 값갱신된 새로운 값
DELETE삭제 전의 원래 값모든 필드는 NULL

참고: 오라클과 MySQL은 OLD, NEW라는 이름을 사용하지만, MS-SQL에서는 DELETED, INSERTED라는 이름을 사용한다.

트리거 관리

트리거 삭제

DROP TRIGGER trigger_name;

트리거 비활성화/활성화

ALTER TRIGGER trigger_name {DISABLE | ENABLE};

트리거 정보 조회
트리거가 생성될 때 소스 코드는 데이터 사전 VIEW인 user_triggers에 저장된다.

SELECT * FROM user_triggers WHERE table_name='테이블명';

기본 트리거 예제

테이블 생성

CREATE TABLE tri_emp
AS
  SELECT empno, ename FROM emp WHERE 1=2;

INSERT 트리거

CREATE OR REPLACE TRIGGER tri_01 
AFTER INSERT ON tri_emp
BEGIN
    DBMS_OUTPUT.PUT_LINE('신입사원 입사');
END;
INSERT INTO tri_emp(empno, ename) VALUES(100, '홍길동');
-- 출력: 신입사원 입사

UPDATE 트리거

CREATE OR REPLACE TRIGGER tri_02
AFTER UPDATE ON tri_emp
BEGIN
  DBMS_OUTPUT.PUT_LINE('신입사원 수정');
END;
UPDATE tri_emp
SET ename='아하'
WHERE empno=100;
-- 출력: 신입사원 수정

DELETE 트리거

CREATE OR REPLACE TRIGGER tri_03
AFTER DELETE ON tri_emp
BEGIN
  DBMS_OUTPUT.PUT_LINE('신입사원 삭제');
END;

하나의 테이블에 여러 개의 트리거(INSERT, UPDATE, DELETE)를 각각 만들 수 있다.

실무 예제: 감사 추적 (Audit Trail)

테이블에 INSERT, UPDATE, DELETE를 할 때 사용자(user), 구분(I, U, D), 시간(sysdate)을 기록하는 테이블(emp_audit)에 내용을 저장하는 예제다.

준비 작업

-- 시퀀스 생성
CREATE SEQUENCE emp_audit_tr
  INCREMENT BY 1
  START WITH 1
  MAXVALUE 999999
  MINVALUE 1
  NOCYCLE
  NOCACHE;

-- 추적 테이블 생성
CREATE TABLE emp_audit(
  e_id NUMBER(6) CONSTRAINT emp_audit_pk PRIMARY KEY,
  e_name VARCHAR2(30),
  e_gubun VARCHAR2(10),
  e_date DATE
);

-- 테스트용 테이블 생성
CREATE TABLE emp2
AS
  SELECT * FROM emp;

문장 레벨 트리거 (FOR EACH ROW 없음)

CREATE OR REPLACE TRIGGER emp_audit_tr
AFTER INSERT OR UPDATE OR DELETE ON emp2
BEGIN
  IF INSERTING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'inserting', SYSDATE);
  ELSIF UPDATING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'updating', SYSDATE);
  ELSIF DELETING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'deleting', SYSDATE);
  END IF;
END;
-- 3개 행을 업데이트해도 로그는 1건만 기록된다
UPDATE emp2 
SET deptno = 20
WHERE deptno = 10;

SELECT * FROM emp_audit;
-- 결과: 1건의 'updating' 로그

FOR EACH ROW를 선언하지 않았을 때는 명령어 한 번에 대하여 한 건으로 기록된다. 실제로는 3번 업데이트되지만 단건처럼 나온다.

행 레벨 트리거 (FOR EACH ROW 사용)

CREATE OR REPLACE TRIGGER emp_audit_tr
AFTER INSERT OR UPDATE OR DELETE ON emp2
FOR EACH ROW
BEGIN
  IF INSERTING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'inserting', SYSDATE);
  ELSIF UPDATING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'updating', SYSDATE);
  ELSIF DELETING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'deleting', SYSDATE);
  END IF;
END;
-- 3개 행을 업데이트하면 로그도 3건 기록된다
UPDATE emp2 
SET deptno = 20
WHERE deptno = 10;

SELECT * FROM emp_audit;
-- 결과: 3건의 'updating' 로그

FOR EACH ROW를 선언했을 때는 명령어 한 번에 변경된 행만큼 기록된다.

OLD와 NEW를 활용한 변경 이력 추적

INSERT, UPDATE, DELETE로 변경되는 내용에 대하여 전/후 데이터를 기록하는 고급 감사 추적 예제다.

테이블 생성

CREATE TABLE emp_audit (
  id NUMBER(6) CONSTRAINT emp_audit_pk PRIMARY KEY,
  name VARCHAR2(30),
  gubun VARCHAR2(10),
  wdate DATE,
  etc1 VARCHAR2(20),  -- old 값
  etc2 VARCHAR2(20)   -- new 값
);

OLD/NEW 사용 트리거

CREATE OR REPLACE TRIGGER emp_audit_tr
AFTER INSERT OR UPDATE OR DELETE ON emp2
FOR EACH ROW
BEGIN
  IF INSERTING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'inserting', SYSDATE, 
           :OLD.deptno, :NEW.deptno);
  ELSIF UPDATING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'updating', SYSDATE, 
           :OLD.deptno, :NEW.deptno);
  ELSIF DELETING THEN
    INSERT INTO emp_audit
    VALUES(emp_audit_tr.NEXTVAL, USER, 'deleting', SYSDATE, 
           :OLD.deptno, :NEW.deptno);
  END IF;
END;

:OLD는 기존 데이터가 가상 테이블에 보관되어 있고, :NEW는 새로운 데이터를 의미한다.

테스트

-- INSERT 테스트
INSERT INTO emp2(empno, ename, deptno) VALUES (9999, '홍길동', 100);
SELECT * FROM emp_audit;
-- etc1(old): NULL, etc2(new): 100

-- UPDATE 테스트
UPDATE emp2 SET deptno=200 WHERE empno=9999;
SELECT * FROM emp_audit;
-- etc1(old): 100, etc2(new): 200

-- DELETE 테스트
DELETE FROM emp2 WHERE empno=9999;
SELECT * FROM emp_audit;
-- etc1(old): 200, etc2(new): NULL

BEFORE 트리거를 활용한 데이터 검증

관리자가 자주 사용하는 예제로, 특정 시간대나 특정 조건에서 데이터 입력을 제한할 수 있다.

시간 제한 트리거

점심시간이나 출장 갔을 때 작업이 안 되게 막고 싶을 때 BEFORE 트리거로 미리 검사한다.

CREATE TABLE tri_order
(
  no NUMBER,
  ord_code VARCHAR2(10),
  ord_date DATE
);

CREATE OR REPLACE TRIGGER trigger_order
BEFORE INSERT ON tri_order
BEGIN
  IF(TO_CHAR(SYSDATE, 'HH24:MI') NOT BETWEEN '11:00' AND '16:00') THEN
    RAISE_APPLICATION_ERROR(-20002, '허용시간 오류 쉬세요');
  END IF;
END;

BEFORE 트리거의 동작 시점이 실제 tri_order 테이블에 INSERT 되기 전에 트리거가 먼저 동작하고, 그 이후 INSERT 작업이 진행된다.

-- 11시~16시 사이에만 성공
INSERT INTO tri_order VALUES(1, 'notebook', SYSDATE);

데이터 값 검증 트리거

DBA가 "이번 달은 데스크탑 받지 마!" 라고 했을 때, 들어오는 데이터를 미리 검사하고 desktop이면 오류를 발생시킨다.

CREATE OR REPLACE TRIGGER tri_order2
BEFORE INSERT ON tri_order
FOR EACH ROW
BEGIN
  IF(:NEW.ord_code) NOT IN('desktop') THEN
    RAISE_APPLICATION_ERROR(-20002, '제품코드 오류');
  END IF;
END;
-- 오류 발생 (notebook은 안 됨)
INSERT INTO tri_order VALUES(200, 'notebook', SYSDATE);

-- 성공 (desktop만 허용)
INSERT INTO tri_order VALUES(200, 'desktop', SYSDATE);

입고-재고 연동 예제

입고 테이블에 데이터가 들어오면 자동으로 재고 테이블에도 반영되는 실무에서 가장 많이 사용하는 패턴이다.

테이블 생성

CREATE TABLE t_01  -- 입고
(
  no NUMBER,
  pname VARCHAR2(20)
);

CREATE TABLE t_02  -- 재고
(
  no NUMBER,
  pname VARCHAR2(20)
);

INSERT 트리거: 입고 시 재고 증가

CREATE OR REPLACE TRIGGER insert_t_01
AFTER INSERT ON t_01
FOR EACH ROW
BEGIN
  INSERT INTO t_02(no, pname)
  VALUES(:NEW.no, :NEW.pname);
END;
-- 입고
INSERT INTO t_01 VALUES(1, 'notebook');

SELECT * FROM t_01;  -- 1건
SELECT * FROM t_02;  -- 1건 (자동 생성)

입고 데이터가 들어오면 가상 테이블 NEW를 이용해서 같은 데이터를 재고에 자동으로 입력한다.

주의: 만약 연결이 길었는데 성공-성공-성공-실패 시 전부 롤백된다. 트랜잭션을 물고 있어서 다른 사람 작업이 안 된다.

UPDATE 트리거: 입고 변경 시 재고 변경

CREATE OR REPLACE TRIGGER update_t_01
AFTER UPDATE ON t_01
FOR EACH ROW
BEGIN
  UPDATE t_02
  SET pname = :NEW.pname
  WHERE no = :OLD.no;
END;
UPDATE t_01
SET pname = 'notebook2'
WHERE no = 1;

SELECT * FROM t_01;  -- notebook2
SELECT * FROM t_02;  -- notebook2 (자동 변경)

DELETE 트리거: 입고 삭제 시 재고 삭제

CREATE OR REPLACE TRIGGER delete_tri_01
AFTER DELETE ON t_01
FOR EACH ROW
BEGIN
  DELETE FROM t_02
  WHERE no = :OLD.no;
END;
DELETE FROM t_01 WHERE no=1;

SELECT * FROM t_01;  -- 0건
SELECT * FROM t_02;  -- 0건 (자동 삭제)

로그 기록 예제

전화번호가 바뀌었을 때만 로그 테이블에 기록한다.

CREATE OR REPLACE TRIGGER trg_audit_phone_change
AFTER UPDATE OF phone ON customer  --특정 컬럼의 데이터 변경 
FOR EACH ROW
WHEN (OLD.phone IS NOT NULL AND NEW.phone IS NOT NULL AND OLD.phone <> NEW.phone)
BEGIN
  INSERT INTO audit_log (cust_id, old_phone, new_phone)
  VALUES (:OLD.cust_id, :OLD.phone, :NEW.phone);
END;

삭제 전 백업 트리거 예제

사원이 삭제되기 전에 삭제되기 전의 데이터를 emp_backup 백업 테이블에 저장한다.

CREATE TABLE emp (
  empno   NUMBER PRIMARY KEY,
  ename   VARCHAR2(50),
  job     VARCHAR2(50),
  sal     NUMBER
);
CREATE TABLE emp_backup AS SELECT * FROM emp WHERE 1=0;  -- 구조만 복사


CREATE OR REPLACE TRIGGER trg_backup_before_delete
BEFORE DELETE ON emp
FOR EACH ROW
BEGIN
  INSERT INTO emp_backup (empno, ename, job, sal)
  VALUES (:OLD.empno, :OLD.ename, :OLD.job, :OLD.sal);
END;

실무 활용 아이디어

사용 목적예시 트리거 내용
변경 이력 기록고객 정보 변경 시 로그 테이블에 INSERT
데이터 보존/백업DELETE 전에 백업 테이블에 저장
자동 계산총금액·세금 등 파생 값 자동 계산
제약 조건 대체특정 UPDATE 차단 (예: 관리자 계정 비활성화 금지)
동기화 작업마스터 테이블 변경 시 서브 테이블 자동 반영

트리거 사용 시 주의사항

성능 문제

트리거는 CPU와 메모리를 많이 소비하는 비싼 자원이다. 특히 행 레벨 트리거에서 수만 건의 데이터를 한 번에 처리하면 성능이 급격히 저하될 수 있다. 가능하면 배치 작업이나 프로시저로 처리하는 것을 고려해야 한다.

트랜잭션 길이

트리거가 여러 테이블을 연쇄적으로 수정하면 트랜잭션이 길어지고 락(lock)이 오래 유지된다. 이는 동시 사용자가 많은 시스템에서 심각한 병목 현상을 유발한다. 만약 연결이 길었는데 성공-성공-성공-실패 시 전부 롤백되므로, 다른 사람의 작업도 대기하게 된다.

순환 참조

A 테이블의 트리거가 B 테이블을 수정하고, B 테이블의 트리거가 다시 A 테이블을 수정하면 무한 루프에 빠질 수 있다. 트리거 설계 시 반드시 순환 참조를 확인해야 한다.

디버깅의 어려움

트리거는 자동으로 실행되므로 문제가 발생했을 때 원인을 찾기 어렵다. 어떤 트리거가 언제 실행되었는지 추적하기 힘들고, 여러 트리거가 연쇄적으로 실행되면 더욱 복잡해진다.

트리거 관리 원칙

트리거를 사용할 때는 다음 원칙을 지켜야 한다:

  • 트리거는 최소한으로만 사용한다
  • 복잡한 로직은 트리거 대신 프로시저나 애플리케이션에서 처리한다
  • 트리거 내부에서는 간단한 INSERT/UPDATE만 수행한다
  • 트리거의 존재와 동작을 명확히 문서화한다
  • 정기적으로 사용하지 않는 트리거를 정리한다

트리거 vs 프로시저 vs 제약조건

구분트리거프로시저제약조건
실행 방식자동 실행명시적 호출자동 검증
용도연쇄 작업비즈니스 로직데이터 무결성
성능낮음중간높음
디버깅어려움보통쉬움

단순한 데이터 검증은 제약조건으로, 복잡한 비즈니스 로직은 프로시저로, 자동화된 연쇄 작업만 트리거로 처리하는 것이 좋다.


참고자료

개발블로그 - MYSQL/트리거

profile
개발자 소희의 노트

0개의 댓글