컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
db · 17

저장 프로시저 / 트리거

SQL트리거프로시저

저장 프로시저(Stored Procedure)는 여러 SQL 문과 제어 흐름을 데이터베이스 서버에 저장해 두고 이름으로 호출해 실행하는 기능입니다. 앱이 문장을 하나씩 보내는 대신 호출 한 번으로 묶음을 실행하므로 네트워크 왕복을 줄이고 업무 로직을 DB에 모아 둘 수 있습니다.

트리거(Trigger)는 테이블에서 INSERT, UPDATE, DELETE 같은 이벤트가 일어나면 자동으로 실행되는 코드입니다. PostgreSQL에서는 트리거 함수를 CREATE TRIGGER로 이벤트에 연결합니다. 호출하지 않아도 실행되어 변경 이력 기록이나 값 검증에 쓰이지만, 코드에 보이지 않는 동작이라 남용하면 문제가 됩니다. 이 토픽에서는 프로시저의 왕복 감소, 트리거의 자동 실행, 트리거가 쌓일 때의 부작용을 시각화로 확인합니다.

핵심 개념

저장 프로시저

DB 안에 저장해 두고 이름으로 호출하는 SQL과 제어 흐름의 묶음입니다. 호출은 CALL 같은 문장으로 합니다.

핵심 개념

왕복 감소

여러 문장을 하나씩 보내면 문장마다 앱과 서버가 주고받지만, 프로시저는 호출 한 번으로 서버 안에서 처리합니다.

핵심 개념

트리거

지정한 테이블의 이벤트가 일어나면 자동으로 실행되는 DB 객체입니다. 사용자가 직접 호출하지 않습니다.

핵심 개념

BEFORE와 AFTER

PostgreSQL 같은 DB에서는 BEFORE 트리거가 변경 전에 값을 검증하거나 바꿀 수 있고, AFTER 트리거는 변경 후에 이력을 남깁니다. SQL Server는 BEFORE 대신 INSTEAD OF를 씁니다.

핵심 개념

OLD와 NEW

PostgreSQL의 행 단위 트리거는 UPDATE에서 변경 전 행(OLD)과 변경 후 행(NEW)을 모두 읽습니다. INSERT에는 OLD가, DELETE에는 NEW가 없습니다.

핵심 개념

숨은 동작

트리거는 앱 코드에 보이지 않은 채 실행됩니다. 추적과 디버깅이 어렵고 쓰기 성능에도 영향을 줍니다.

프로시저는 왕복을 줄입니다 — 서버와 주고받는 횟수가 줄고, 업무 규칙을 한곳에 모아 여러 앱이 같은 규칙을 쓰게 할 수 있습니다.
대신 DB 안 코드를 관리해야 합니다 — 프로시저 언어와 문법이 DB마다 달라 다른 DB로 옮기기 어렵고, DB 안의 코드를 팀의 배포와 테스트 흐름에 맞춰 관리해야 합니다.
트리거는 규칙이 빠질 가능성을 줄입니다 — 지정한 이벤트에서는 어느 앱이 데이터를 바꾸든 같은 이력 기록과 검증이 실행됩니다. TRUNCATE나 트리거 비활성화처럼 예외도 있습니다.
트리거 남용이 문제입니다 — 보이지 않는 동작이 쌓이면 원인 추적이 어렵고, 데이터를 바꿀 때마다 추가 작업이 들어가 쓰기 성능이 떨어질 수 있습니다.
이 시각화에서 확인할 것 — "저장 프로시저" 탭에서 이체 문장을 하나씩 보내면 왕복 3번, 프로시저를 호출하면 1번인 모습을, "트리거" 탭에서 UPDATE 하나에 audit_log INSERT가 자동으로 따라오는 모습을, "트리거의 부작용" 탭에서 쿼리 1개가 숨은 쓰기까지 합쳐 4번이 되는 모습을 STEP으로 확인하세요.

프로시저와 트리거의 배경

저장 프로시저는 DB마다 고유한 언어를 씁니다. Oracle의 PL/SQL, SQL Server의 T-SQL, PostgreSQL의 PL/pgSQL이 그 예이고, PL/pgSQL은 SQL에 제어 구조를 더한 언어입니다. 어떤 DB에서는 프로시저와 함수를 구분하며, PostgreSQL에서는 11 버전부터 CREATE PROCEDURE와 CALL을 지원합니다.

트리거는 SQL 표준에도 포함된 기능이고 PostgreSQL, Oracle, SQL Server, SQLite 같은 주요 데이터베이스가 지원합니다. 변경 이력 기록, 값 검증, 파생 값 갱신 같은 규칙을 데이터베이스 안에서 강제하는 용도로 쓰입니다. 구체적인 문법과 실행 시점의 종류는 DB마다 다릅니다.

숫자로 확인하기

왕복 3번 대 1번 — 이체를 SELECT 1번과 UPDATE 2번으로 하나씩 보내면 왕복이 3번이고, 프로시저를 호출하면 1번입니다.
트리거 이력 — 이체 한 번은 UPDATE 2번이고 트리거가 각각 이력을 남기므로 audit_log에 2행이 쌓입니다. 코드에서 이체 두 번을 실행하면 이력은 4건입니다.
쿼리 1개, 쓰기 4번 — 가상의 예로 주문 INSERT 하나에 재고, 이력, 요약 테이블을 갱신하는 트리거 3개가 붙으면 앱이 보낸 쿼리는 1개인데 쓰기는 4번입니다.
코드 확인 — 파이썬 모델은 앱이 하나씩 보낼 때 왕복 3, 프로시저를 호출할 때 왕복 1을 출력하고 두 번 모두 잔액이 100씩 옮겨져 최종 {1: 800, 2: 700}이 됩니다.

자주 하는 오해

프로시저는 항상 더 빠르다 — 왕복이 줄어 이점이 있을 수 있지만, 쿼리 자체가 하는 일은 같고 줄어드는 것은 왕복 비용입니다. 문장이 하나뿐이거나 왕복 비용이 작으면 차이가 거의 없습니다.
업무 로직은 모두 DB에 두는 것이 좋다 — 프로시저 문법은 DB마다 달라 옮기기 어렵고, 어디에 둘지는 요구에 따라 정합니다.
트리거는 호출해야 실행된다 — 트리거는 이벤트가 일어나면 자동으로 실행되며 사용자가 직접 호출하지 않습니다.
트리거는 공짜다 — 데이터를 바꿀 때마다 트리거가 추가 작업을 하므로 쓰기 지연이 늘고, 트리거가 다른 트리거를 부르면 비용이 쌓입니다.
프로시저와 함수는 같다 — 둘은 비슷하지만 DB에 따라 호출 방식과 반환 값, 트랜잭션 제어 가능 여부가 다릅니다.

면접에서 이렇게 물어요

면접 질문

저장 프로시저란?

여러 SQL 문과 제어 흐름을 DB 서버에 저장해 두고 이름으로 호출해 실행하는 기능입니다. 네트워크 왕복을 줄이고 로직을 DB에 모아 둘 수 있습니다.

면접 질문

트리거란?

INSERT, UPDATE, DELETE 같은 이벤트가 일어나면 자동으로 실행되는 DB 객체입니다. 변경 이력 기록이나 값 검증에 씁니다.

면접 질문

트리거를 남용하면 어떤 문제가 생기나요?

코드에서 보이지 않는 숨은 동작이 생겨 추적과 디버깅이 어렵고, 데이터 변경마다 추가 작업이 들어가 쓰기 성능에 영향을 줄 수 있습니다.

면접 질문

프로시저와 애플리케이션 로직 중 어디에 규칙을 두나요?

정해진 답은 없습니다. 여러 앱이 같은 규칙을 써야 하거나 왕복을 줄여야 하면 DB, 다른 DB로 옮길 가능성이 있으면 애플리케이션에 두는 쪽이 유리할 수 있습니다.

활용 분야

변경 이력과 감사 기록 — 트리거로 누가 무엇을 바꿨는지 자동으로 감사 테이블에 남깁니다.
여러 문장으로 된 업무 처리 — 이체나 정산처럼 여러 테이블을 정해진 순서로 바꾸는 작업을 프로시저로 묶어 일관되게 실행합니다.
배치와 데이터 정리 — 주기적으로 실행하는 집계나 정리 작업을 프로시저로 두고 스케줄러가 호출합니다.

함께 보면 좋은 토픽

트랜잭션 — 프로시저 안의 여러 문장이 하나의 단위로 실행되는 방식을 봅니다.
락 — 트리거가 추가로 바꾸는 행에 걸리는 잠금과의 관계를 봅니다.
쿼리 실행 계획 — 쓰기 지연을 만드는 쿼리의 실행 방식을 확인합니다.
정규화 — 파생 값을 트리거로 유지하는 설계와 정규화의 관계를 봅니다.

참고 자료

공식 문서 — PostgreSQL: CREATE PROCEDURE
공식 문서 — PostgreSQL: CALL
공식 문서 — PostgreSQL: Triggers
공식 문서 — PostgreSQL: CREATE TRIGGER
공식 문서 — PostgreSQL: PL/pgSQL

SQL — PostgreSQL의 프로시저와 트리거

CREATE PROCEDURE transfer(src int, dst int, amt int)
LANGUAGE plpgsql
AS $$
BEGIN
    IF (SELECT balance FROM account WHERE id = src) < amt THEN
        RAISE EXCEPTION '잔액 부족';
    END IF;
    UPDATE account SET balance = balance - amt WHERE id = src;
    UPDATE account SET balance = balance + amt WHERE id = dst;
END;
$$;

CALL transfer(1, 2, 100);                          -- 앱은 한 번만 호출

CREATE FUNCTION log_balance() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO audit_log(account_id, old_balance, new_balance)
    VALUES (OLD.id, OLD.balance, NEW.balance);   -- OLD = 변경 전 행, NEW = 변경 후 행
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_balance_audit
AFTER UPDATE OF balance ON account
FOR EACH ROW EXECUTE FUNCTION log_balance();

PostgreSQL 11부터 CREATE PROCEDURE로 프로시저를 만들고 CALL로 호출합니다. transfer는 잔액을 확인하고 두 계좌를 수정하는 문장을 프로시저 안에 모아 두어, 앱은 CALL 한 번으로 이체를 실행합니다. 시각화의 저장 프로시저 탭이 보여 주는 동작입니다. 트리거는 트리거 함수를 만들고 CREATE TRIGGER로 테이블의 이벤트에 연결합니다. 여기서는 account의 UPDATE가 balance를 SET 목록에 포함하면(값이 같아도) 각 행마다 log_balance가 자동으로 실행되어 변경 전후 값을 audit_log에 남깁니다. 앱이 audit_log를 호출하는 코드는 없습니다. 프로시저 언어와 문법은 DB마다 다르며, 위 예는 PostgreSQL의 PL/pgSQL입니다. AFTER 행 단위 트리거의 반환 값은 무시되며 RETURN NEW는 관례입니다(BEFORE 트리거에서는 반환 값이 저장될 행을 정합니다). account와 audit_log 테이블이 이미 있다고 가정한 조각이고, transfer는 동시 이체를 막는 잠금을 넣지 않은 예시입니다.

Python — 왕복 횟수와 트리거 흉내

class Db:
    def __init__(self):
        self.bal = {1: 1000, 2: 500}
        self.audit = []
        self.trips = 0
        self.triggers = []

    def _update(self, acc, delta):
        old = self.bal[acc]
        self.bal[acc] += delta
        for t in self.triggers:                   # 이벤트가 나면 트리거가 자동 실행
            t(acc, old, self.bal[acc])

    def run(self, sql, *args):                    # 앱이 보내는 문장 하나 = 왕복 1번
        self.trips += 1
        if sql == 'select':
            return self.bal[args[0]]
        self._update(*args)

    def call_transfer(self, src, dst, amt):       # 프로시저 호출 = 왕복 1번
        self.trips += 1
        if self.bal[src] < amt:
            raise ValueError('잔액 부족')
        self._update(src, -amt)                   # 나머지는 DB 안에서 처리
        self._update(dst, amt)

db = Db()
db.triggers.append(lambda acc, old, new: db.audit.append((acc, old, new)))

if db.run('select', 1) >= 100:                    # 프로시저 없이: 문장마다 왕복
    db.run('update', 1, -100)
    db.run('update', 2, 100)
print('앱이 하나씩:', db.trips, db.bal)

db.trips = 0
db.call_transfer(1, 2, 100)                       # 프로시저: 한 번 호출
print('프로시저:', db.trips, db.bal)
print('트리거가 남긴 이력:', len(db.audit), db.audit[0])
# 앱이 하나씩: 3 {1: 900, 2: 600}
# 프로시저: 1 {1: 800, 2: 700}
# 트리거가 남긴 이력: 4 (1, 1000, 900)

앱이 문장을 하나씩 보내면 SELECT 1번과 UPDATE 2번으로 왕복이 3번이고, 프로시저를 호출하면 왕복이 1번입니다. 시각화의 저장 프로시저 탭과 같은 결과입니다. 두 번 모두 잔액이 100씩 옮겨져 최종 {1: 800, 2: 700}이 됩니다. _update는 값이 바뀔 때마다 등록된 트리거 함수를 자동으로 호출해 (계좌, 변경 전, 변경 후)를 audit에 남깁니다. 이체 두 번에 UPDATE가 모두 4번 일어났으므로 이력도 4건이고, 앱 코드 어디에도 audit에 쓰는 호출은 없습니다. 시각화의 트리거 탭과 같은 동작입니다. 장난감 모델이고 실제 DB의 트랜잭션과 오류 처리는 훨씬 복잡합니다.

Java — JDBC로 프로시저 호출

// 프로시저 없이: 문장마다 서버와 주고받는다
try (Connection conn = ds.getConnection()) {
    conn.setAutoCommit(false);
    int bal;
    try (PreparedStatement ps = conn.prepareStatement("SELECT balance FROM account WHERE id = ?")) {
        ps.setInt(1, 1);
        try (ResultSet rs = ps.executeQuery()) { rs.next(); bal = rs.getInt(1); }
    }
    if (bal >= 100) { /* UPDATE 두 번 */ }
    conn.commit();
}

// 프로시저: 한 번 호출 (PostgreSQL 11 이상의 CALL 문)
try (Connection conn = ds.getConnection();
     PreparedStatement ps = conn.prepareStatement("CALL transfer(?, ?, ?)")) {
    ps.setInt(1, 1);
    ps.setInt(2, 2);
    ps.setInt(3, 100);
    ps.execute();                                   // 잔액 부족이면 SQLException
}

위쪽은 앱이 SELECT와 UPDATE를 하나씩 보내며 잔액 판단을 자바 코드에서 합니다. 아래쪽은 CALL transfer 한 번으로 모든 처리를 DB에 맡기고, 프로시저가 RAISE EXCEPTION을 내면 JDBC에서는 SQLException으로 받습니다. 표준 JDBC에는 CallableStatement의 {call ...} 문법도 있지만 pgJDBC는 기본 설정에서 이를 함수 호출로 바꾸므로 프로시저에는 escapeSyntaxCallMode 설정이 필요합니다. 아래 prepareStatement 방식은 서버가 CALL 문을 받는다는 점에 기댄 예시이므로 사용하는 드라이버 문서를 확인하세요. 프로시저 안에 업무 규칙이 들어가면 그 규칙은 자바 코드가 아니라 DB에서 바뀌고 배포됩니다. 규칙을 한곳에 두는 장점이 있습니다.

Q1

저장 프로시저(Stored Procedure)란?

함수처럼 미리 만들어 두고 호출합니다.
저장 프로시저는 여러 SQL 문과 제어 흐름을 DB 서버에 저장해 두고 이름으로 호출해 실행하는 기능입니다. 네트워크 왕복을 줄이고 로직을 DB에 모아둘 수 있습니다.

Q2

트리거(Trigger)란?

"방아쇠" — 특정 이벤트가 당기는 순간 실행됩니다.
트리거는 지정한 테이블의 INSERT/UPDATE/DELETE 같은 이벤트가 일어날 때 자동으로 실행됩니다. 변경 이력 기록이나 값 검증 등에 사용합니다.

Q3

트리거를 남용했을 때 생길 수 있는 문제는?

애플리케이션 코드에는 나타나지 않는 동작입니다.
트리거는 애플리케이션 코드에 보이지 않은 채 자동 실행되어 동작을 추적하기 어렵습니다. 또 데이터 변경마다 추가 작업이 들어가 쓰기 성능이 떨어질 수 있습니다.