애플리케이션을 개발하다 보면 특정 데이터베이스 작업이 발생했을 때 자동으로 다른 작업을 수행해야 하는 경우가 종종 있다. 예를 들어, 상품 가격이 변경될 때마다 그 기록을 남기거나, 새로운 사용자가 생성될 때 환영 이메일을 보내는 것과 같은 일이다.
이런 자동화된 작업을 처리하는 가장 강력하고 안정적인 방법 중 하나가 바로 데이터베이스 트리거(Trigger)다. 이 글에서는 PostgreSQL을 기준으로 트리거가 무엇인지, 어떻게 사용하는지, 그리고 비즈니스 로직과 비교하여 왜 중요한지 자세히 알아본다.
목차
1. 트리거의 의미
2. PostgreSQL 트리거 예시: 상품 가격 변경 로그 남기기
3. 트리거 사용 vs. 미사용: 데이터 무결성 관점
4. 트리거 vs. 비즈니스 로직 유효성 검증
5. 트리거의 고급 활용
트리거는 데이터베이스에서 특정 이벤트(예: INSERT
, UPDATE
, DELETE
)가 발생할 때 자동으로 실행되는 함수다. 이는 마치 "방아쇠"와 같아서, 특정 조건이 충족되면 미리 정의된 액션을 발동시키는 역할을 한다.
트리거의 핵심 요소
이벤트 : 트리거를 실행시킬 데이터베이스 작업이다 (INSERT
, UPDATE
, DELETE
또는 TRUNCATE
).
실행 시점 : 이벤트가 발생하기 전(BEFORE
) 또는 발생한 후(AFTER
)에 트리거를 실행할지 결정한다. 예를 들어, 데이터가 삽입되기 전에 유효성을 검사하거나, 데이터가 삭제된 후에 기록을 남길 수 있다.
실행 대상 : 트리거를 테이블의 각 행(FOR EACH ROW)에 대해 실행할지, 아니면 전체 구문(FOR EACH STATEMENT)에 대해 한 번만 실행할지 결정한다.
목표: products
테이블에서 price 컬럼이 변경될 때마다, product_log
테이블에 변경 기록을 자동으로 남기는 트리거를 생성한다.
먼저, 상품 정보와 로그를 저장할 두 개의 테이블을 만든다.
-- 상품 정보를 저장하는 테이블
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
updated_at TIMESTAMP
);
-- 상품 가격 변경 로그를 저장하는 테이블
CREATE TABLE product_log (
log_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
old_price DECIMAL(10, 2) NOT NULL,
new_price DECIMAL(10, 2) NOT NULL,
changed_at TIMESTAMP NOT NULL
);
트리거가 실행할 실제 로직을 담는 함수를 PL/pgSQL
로 작성한다. 이 함수는 NEW
와 OLD
라는 특별한 레코드를 사용해 변경 전/후의 데이터에 접근할 수 있다.
OLD
: UPDATE
또는 DELETE
이벤트에서 변경 전 행의 데이터를 담고 있다.
NEW
: INSERT
또는 UPDATE
이벤트에서 변경될(또는 이미 변경된) 새 행의 데이터를 담고 있다.
CREATE OR REPLACE FUNCTION log_price_change()
RETURNS TRIGGER AS $$
BEGIN
-- OLD.price와 NEW.price가 다를 경우에만 로그를 남김
IF OLD.price IS DISTINCT FROM NEW.price THEN
INSERT INTO product_log (
product_id,
old_price,
new_price,
changed_at
) VALUES (
OLD.id,
OLD.price,
NEW.price,
NOW()
);
END IF;
RETURN NEW; -- UPDATE를 계속 진행하도록 NEW를 반환
END;
$$ LANGUAGE plpgsql;
이제 위에서 만든 함수를 products 테이블의 UPDATE 이벤트에 연결하는 트리거를 생성한다.
CREATE TRIGGER products_price_update_trigger
AFTER UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION log_price_change();
AFTER UPDATE ON products
: products 테이블에서 UPDATE 이벤트가 발생한 후에 트리거를 실행한다.
FOR EACH ROW
: 각 행이 업데이트될 때마다 트리거를 실행한다.
EXECUTE FUNCTION log_price_change()
: 실행할 함수를 지정한다.
이제 products 테이블의 데이터를 수정해보며 트리거가 잘 작동하는지 확인해 본다.
-- 초기 데이터 삽입
INSERT INTO products (name, price) VALUES ('노트북', 1500.00);
-- 가격 변경 (트리거 발동)
UPDATE products SET price = 1450.00, updated_at = NOW() WHERE id = 1;
-- 가격 변경 (트리거 발동 안 함 - 가격이 동일)
UPDATE products SET name = '경량 노트북' WHERE id = 1;
product_log
테이블을 조회하면, 첫 번째 UPDATE
에 대한 로그만 정확히 기록된 것을 확인할 수 있다.
SELECT * FROM product_log;
log_id | product_id | old_price | new_price | changed_at |
---|---|---|---|---|
1 | 1 | 1500.00 | 1450.00 | 2023-10-27 10:30:00 |
트리거를 사용하지 않고 애플리케이션에서 직접 가격 변경 로그를 남기는 것도 가능하다. 하지만 데이터 무결성이라는 측면에서 큰 차이가 발생한다.
트리거 미사용 (애플리케이션 로직) : 상품 가격을 변경하는 모든 애플리케이션 코드(예: REST API, 배치 작업, 관리자 도구)에 UPDATE
쿼리 후에 INSERT
로그 쿼리를 추가해야 한다. 만약 개발자가 실수로 한 곳에서 로그 코드를 누락하거나, 데이터베이스에 직접 접근하여 쿼리를 실행하면 로그가 누락될 수 있다.
트리거 사용 (데이터베이스 로직) : 로그를 남기는 로직이 데이터베이스 레벨에 중앙화된다. 어떤 경로로든 products
테이블의 price가 변경되면, 데이터베이스 자체가 이를 감지하고 로그를 생성하는 것을 보장한다.
이처럼 트리거는 애플리케이션의 복잡성을 줄이고, 데이터의 일관성과 신뢰성을 철저하게 보장하는 역할을 한다.
트리거 : 데이터베이스의 핵심 무결성 규칙을 강제하는 데 적합하다. 예를 들어, orders 테이블에 price가 0보다 큰 값만 들어가야 한다거나, stock 재고가 음수가 되면 안 된다는 규칙 등은 트리거로 구현하기에 적합하다. 이 규칙은 어떤 애플리케이션도 우회할 수 없다.
애플리케이션 비즈니스 로직 : 고차원적인 유효성 검증에 적합하다. 예를 들어, '사용자가 5번 이상 로그인 실패 시 계정을 잠금'과 같은 로직은 외부 서비스와의 연동이나 복잡한 상태 관리가 필요하므로 애플리케이션에서 처리하는 것이 훨씬 효율적이다.
결론적으로, 트리거는 데이터의 '존재'와 '형식'에 대한 필수적인 규칙을, 애플리케이션 로직은 데이터의 '상황'과 '맥락'에 대한 규칙을 담당한다고 볼 수 있다.
트리거 함수 내부에서 IF
문을 사용하는 대신, WHEN
조건을 사용해 트리거를 실행할지 말지 미리 결정할 수 있다. 이렇게 하면 조건이 맞지 않는 경우 트리거 함수 호출 자체를 건너뛰므로 성능을 향상시킬 수 있다.
-- WHEN 조건을 사용한 트리거
CREATE TRIGGER products_price_update_trigger_optimized
AFTER UPDATE ON products
FOR EACH ROW
WHEN (OLD.price IS DISTINCT FROM NEW.price)
EXECUTE FUNCTION log_price_change();
트리거 함수는 반드시 레코드(NEW
) 또는 NULL
을 반환해야 한다. 이 반환값에 따라 동작이 달라진다.
RETURN NEW;
: 함수가 변경된 데이터를 반환하면 UPDATE
또는 INSERT
작업이 계속 진행된다. 함수 내에서 NEW
레코드를 수정하면 그 변경 사항이 최종적으로 테이블에 반영된다.
RETURN NULL;
: 함수가 NULL
을 반환하면 현재 발생한 데이터 변경 작업이 취소된다. BEFORE
트리거에서 RETURN NULL;
을 사용해 특정 조건을 만족하는 행의 삽입이나 업데이트를 막는 데 유용하다.
예시: 음수 주문 수량 방지하기
order_items
테이블에 새로운 주문 항목이 추가될 때, 수량이 음수인지 검사하고 음수면INSERT
를 취소하는BEFORE
트리거를 만든다.CREATE OR REPLACE FUNCTION prevent_negative_quantity() RETURNS TRIGGER AS $$ BEGIN IF NEW.quantity < 0 THEN RAISE NOTICE '주문 수량은 음수가 될 수 없다. 작업을 취소한다.'; RETURN NULL; -- NULL을 반환하여 삽입 작업 취소 END IF; RETURN NEW; -- 정상적으로 진행 END; $$ LANGUAGE plpgsql; CREATE TRIGGER check_order_quantity BEFORE INSERT ON order_items FOR EACH ROW EXECUTE FUNCTION prevent_negative_quantity(); -- 이제 음수 수량을 삽입해 보면 INSERT INTO order_items (product_id, quantity) VALUES (1, -5); -- NOTICE 메시지가 뜨고 INSERT가 실패한다.
트리거 함수가 다른 테이블의 데이터를 수정하고, 그 테이블에 또 다른 트리거가 있다면 연쇄적으로 트리거가 실행되는 현상을 트리거 체이닝이라고 한다. 이는 복잡한 비즈니스 로직을 구현하는 데 유용하지만, 무한 루프에 빠지거나 디버깅이 어려워질 수 있다. PostgreSQL은 트리거 체인 레벨의 기본값을 7로 설정하여 무한 루프를 방지한다.
예시: 주문 시 재고 자동 업데이트
orders
테이블에 주문이 들어오면order_items
테이블에 주문 항목을 추가하고,order_items
테이블에 트리거를 걸어products
테이블의 재고를 자동으로 줄이는 시나리오를 생각해보자.
order_items
테이블에INSERT
가 발생하면,update_stock_trigger
가 실행된다.
update_stock_trigger
는products
테이블의stock
을 업데이트한다.만약
products
테이블에 재고 변경을 기록하는 다른 트리거가 있다면, 이 트리거가 연쇄적으로 실행될 것이다.
일반적인 트리거가 DML(데이터 조작 언어, 예: INSERT
) 이벤트에 반응하는 반면, 이벤트 트리거는 DDL(데이터 정의 언어, 예: CREATE
, DROP
, ALTER
) 이벤트에 반응한다. 이를 사용하면 특정 사용자의 테이블 생성이나 삭제를 막거나, 데이터베이스 스키마 변경 기록을 자동으로 남길 수 있다.
예시: 특정 사용자가 테이블을 삭제하는 것을 방지하는 이벤트 트리거
CREATE OR REPLACE FUNCTION deny_table_drop() RETURNS event_trigger AS $$ BEGIN IF tg_tag = 'DROP TABLE' AND current_user = 'some_user' THEN RAISE EXCEPTION 'User some_user is not allowed to > drop tables.'; END IF; END; $$ LANGUAGE plpgsql; CREATE EVENT TRIGGER deny_drop_tables_for_user ON ddl_command_start WHEN TAG IN ('DROP TABLE') EXECUTE FUNCTION deny_table_drop();
BEFORE
트리거는 트랜잭션이 해당 행에 대해 잠금을 획득한 상태에서 실행된다. 이로 인해 트리거 내부의 로직이 복잡하거나 다른 테이블에 대한 추가 잠금을 필요로 하면 데드락(Deadlock) 이 발생할 위험이 있다. 동시성이 중요한 환경에서는 트리거 내부 로직을 최대한 가볍게 유지하고, 잠금 가능성이 있는 작업을 분리하는 것이 중요하다.
예시: 데드락이 발생할 수 있는 시나리오
user_account 테이블에 잔액을 업데이트하는 트리거가 있고, 이 트리거가 transaction_log 테이블에 기록을 남긴다고 가정해보자.
트랜잭션 1 :
user_account
테이블의 A 행을 업데이트한다.BEFORE
트리거가 실행되어 A 행에 대한 잠금을 획득한다. 이제transaction_log
테이블에 기록을 남기기 위해transaction_log
에 대한 잠금을 요청한다.트랜잭션 2 :
transaction_log
테이블에 기록을 먼저 남긴다.transaction_log
에 대한 잠금을 획득한다. 이제user_account
테이블의 B 행을 업데이트하기 위해 B 행에 대한 잠금을 요청한다.만약 이 두 트랜잭션이 동시에 실행되고, 서로 상대방이 이미 잠근 리소스에 접근하려 할 때, 데드락이 발생하여 두 트랜잭션 모두 멈추게 된다. 이런 상황은 트리거 내부에서 여러 테이블을 조작하거나 외부 리소스에 접근하는 경우 흔히 발생할 수 있다.