저장 프로시저 / 트리거
저장 프로시저(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마다 고유한 언어를 씁니다. 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마다 다릅니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
저장 프로시저란?
여러 SQL 문과 제어 흐름을 DB 서버에 저장해 두고 이름으로 호출해 실행하는 기능입니다. 네트워크 왕복을 줄이고 로직을 DB에 모아 둘 수 있습니다.
면접 질문
트리거란?
INSERT, UPDATE, DELETE 같은 이벤트가 일어나면 자동으로 실행되는 DB 객체입니다. 변경 이력 기록이나 값 검증에 씁니다.
면접 질문
트리거를 남용하면 어떤 문제가 생기나요?
코드에서 보이지 않는 숨은 동작이 생겨 추적과 디버깅이 어렵고, 데이터 변경마다 추가 작업이 들어가 쓰기 성능에 영향을 줄 수 있습니다.
면접 질문
프로시저와 애플리케이션 로직 중 어디에 규칙을 두나요?
정해진 답은 없습니다. 여러 앱이 같은 규칙을 써야 하거나 왕복을 줄여야 하면 DB, 다른 DB로 옮길 가능성이 있으면 애플리케이션에 두는 쪽이 유리할 수 있습니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
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)란?
Q2
트리거(Trigger)란?
Q3
트리거를 남용했을 때 생길 수 있는 문제는?