학습 목표
- 저장 프로시저(IN/OUT/INOUT)와 저장 함수(DETERMINISTIC/READS SQL DATA)를 만들고 호출한다
- 제어문(IF/CASE/WHILE/LOOP/REPEAT), 커서와 핸들러(DECLARE CONTINUE HANDLER)를 쓴다
- SIGNAL/RESIGNAL 로 사용자 정의 에러를 던지고, EXIT HANDLER 로 잡아 롤백한다
- 트리거(BEFORE/AFTER, OLD/NEW)로 재고 차감과 감사로그를 구현한다
- 이벤트 스케줄러(EVENT)로 작업을 예약하고, 저장 프로그램의 실무 한계를 이해한다
선행 스텝: Step 19 예상 소요: 90분
⚠️ 안전 규칙 모든 객체와 테이블에
s20_접두사를 씁니다. 공용 테이블은 변경하지 않습니다. 반드시mysql클라이언트로 실행하세요 (DELIMITER를 사용합니다).실습이 끝나면
cleanup.sql로 전부 삭제합니다.
이 서버의 중요한 전제
practice.sql첫 부분이products의 일부를 복사해s20_products,s20_audit,s20_order_items를 만듭니다.
저장 프로그램의 본문(body)에는 ; 로 끝나는 문장이 여러 개 들어갑니다.
그런데 클라이언트는 ; 를 만나면 "문장 끝"으로 알고 서버에 보내버립니다. 그러면 CREATE PROCEDURE 가 중간에 잘립니다.
그래서 문장 구분자를 잠시 다른 것(//)으로 바꿔 프로그램 전체를 한 덩어리로 보냅니다.
⚠️ 함정 —
DELIMITER는mysql클라이언트 명령어입니다. SQL 이 아닙니다. 그래서 애플리케이션의 DB 드라이버(JDBC 등)에DELIMITER //를 그대로 보내면 에러가 납니다. 애플리케이션에서 프로시저를 만들 땐 드라이버의 멀티스테이트먼트 옵션을 쓰거나 구분자 없이 한 문장으로 보냅니다.
함수는 값을 하나 반환하며 SQL 안에서 컬럼처럼 쓸 수 있습니다.
⚠️ 함정 (이 서버에서 반드시 걸림) — 특성 선언 없이는 함수를 못 만든다 이 서버는 바이너리 로깅이 켜져 있고
log_bin_trust_function_creators = 0입니다. 이 상태에서 함수를 만들면 다음 에러가 납니다.복제 안전성 때문입니다. 함수에 아래 중 하나를 반드시 선언하세요.
DETERMINISTIC: 같은 입력 → 항상 같은 출력 (예: 세금·마진 계산)READS SQL DATA: SELECT 만 함 (데이터 변경 없음)NO SQL: 본문에 SQL 이 없음
결과
테이블을 읽는 함수는 READS SQL DATA 를 씁니다. 없는 값이면 조용히 NULL 입니다.
⚠️ 함정 — 함수를 WHERE 에 쓰면 인덱스가 죽는다
WHERE s20_stock_of(product_id) < 30처럼 컬럼을 함수로 감싸면 인덱스를 못 타고 전 행에 함수를 실행합니다. 게다가READS SQL DATA함수를 큰 테이블의 SELECT 목록에 넣으면 행마다 쿼리가 한 번씩 나가 N+1 문제가 SQL 안에서 벌어집니다. 함수는 편하지만 성능 함정이 큽니다.
프로시저는 값을 반환하지 않고 CALL 로 실행하며, 결과 집합을 내거나 OUT 파라미터로 값을 돌려줍니다.
INOUT 은 받은 값을 읽고 다시 쓸 수 있습니다.
| 모드 | 방향 | 용도 |
|---|---|---|
IN (기본) | 호출자 → 프로시저 | 입력값 |
OUT | 프로시저 → 호출자 | 결과값 (프로시저가 채움) |
INOUT | 양방향 | 받아서 갱신 |
CASE 로 재고를 등급화하는 함수:
결과
LOOP + LEAVE (라벨로 탈출) 로 팩토리얼:
| 반복문 | 조건 검사 시점 | 비고 |
|---|---|---|
WHILE ... DO ... END WHILE | 시작 (0번 실행 가능) | 가장 흔함 |
REPEAT ... UNTIL ... END REPEAT | 끝 (최소 1번 실행) | do-while 형 |
LOOP ... END LOOP | 없음 | LEAVE 로 직접 탈출. 라벨 필수 |
💡 실무 팁 — 반복문보다 집합 연산
WHILE로 한 행씩 INSERT 하는 코드는 대개 단일 SQL 로 바꿀 수 있고 수십~수백 배 빠릅니다. 위s20_fill_numbers같은 반복 INSERT 는tally조인이나 재귀 CTE(Step 09)로 대체하세요. 저장 프로그램의 반복문은 "정말 절차적일 수밖에 없는" 로직에만 쓰는 게 좋습니다.
커서는 결과 행을 하나씩 훑습니다. 끝에 도달하면 NOT FOUND 조건이 발생하므로 CONTINUE HANDLER 로 감지합니다.
결과 (일부)
⚠️ 함정 — DECLARE 순서가 엄격하다 프로시저 안에서
DECLARE는 정해진 순서를 지켜야 합니다: ① 변수/조건 → ② 커서 → ③ 핸들러. 순서가 틀리면 문법 에러입니다. 또NOT FOUND핸들러가 커서뿐 아니라SELECT ... INTO가 0행일 때도 발동한다는 점을 조심하세요. 커서 로직 중간의 다른 SELECT INTO 가 0행이면v_done이 엉뚱하게 1이 될 수 있습니다.
💡 실무 팁 — 커서는 최후의 수단 커서는 느리고 코드가 장황합니다. "행마다 완전히 다른 처리"가 필요한 게 아니라면 집합 SQL 을 쓰세요. 위 리포트도 사실
GROUP_CONCAT한 줄로 됩니다.
SIGNAL SQLSTATE '45000' 으로 사용자 정의 에러를 던집니다. (45000 = 처리되지 않은 사용자 예외)
실제 에러 출력
에러를 잡아서 롤백하려면 DECLARE EXIT HANDLER FOR SQLEXCEPTION 을 씁니다.
GET DIAGNOSTICS 로 에러 메시지를 꺼낼 수 있습니다.
결과
RESIGNAL 은 잡은 에러를 (로그를 남기는 등 처리한 뒤) 다시 던질 때 씁니다. SIGNAL 과 달리 원래 에러 정보를 보존합니다.
트리거는 INSERT/UPDATE/DELETE 가 일어날 때 자동으로 실행됩니다.
NEW : 새로 들어오는 값 (INSERT/UPDATE). BEFORE 트리거에서는 수정 가능OLD : 기존 값 (UPDATE/DELETE)| 타이밍 × 이벤트 | NEW | OLD | 주 용도 |
|---|---|---|---|
| BEFORE INSERT | O | - | 값 검증/보정 |
| AFTER INSERT | O(읽기) | - | 다른 테이블 갱신, 로그 |
| BEFORE UPDATE | O | O | 검증, 변경 감사 |
| BEFORE/AFTER DELETE | - | O | 삭제 로그 |
세 개의 트리거를 겁니다: 주문 수량 검증(BEFORE INSERT), 재고 차감+로그(AFTER INSERT), 재고 음수 방지+변동 로그(BEFORE UPDATE).
주문 한 건을 넣으면 트리거들이 연쇄적으로 동작합니다.
결과 (재고 60 → 57, 감사로그 2줄)
여기서 STOCK 로그가 ORDER 로그보다 먼저 찍힌 것에 주목하세요.
AFTER INSERT트리거가 먼저UPDATE s20_products를 실행하는데, 그 UPDATE 가BEFORE UPDATE트리거(STOCK 로그)를 다시 발동시킵니다. 그게 끝난 뒤에야 ORDER 로그가 찍힙니다. 트리거가 트리거를 부르는 이 연쇄가 디버깅을 어렵게 만드는 주범입니다.
검증 트리거는 SIGNAL 로 작업을 막습니다.
⚠️ 함정 — 트리거는 "디버깅 지옥"이다
- 보이지 않는다.
INSERT한 줄이 다른 테이블 3개를 바꿔도, 코드만 봐서는 알 수 없습니다. 신입이 몇 시간을 헤매는 전형적 원인입니다.- 연쇄된다. 위처럼 트리거가 트리거를 부르면 실행 순서를 추적하기 어렵습니다.
- 성능이 숨는다. 대량 INSERT 가 느린데 원인이 트리거인 걸 아무도 모릅니다.
- 에러 처리가 까다롭다. 트리거 안에서 SIGNAL 이 나면 원래 DML 전체가 롤백됩니다.
- 한 테이블에 같은 타이밍/이벤트 트리거가 여러 개면 실행 순서를
FOLLOWS/PRECEDES로 지정해야 합니다.
이벤트는 예약된 시각/주기에 자동 실행되는 SQL 입니다 (크론과 유사). 전역 event_scheduler = ON 이어야 실제로 돕니다.
등록된 이벤트 확인
💡 실무 팁
EVERY이벤트는 끄고 켤 수 있습니다:ALTER EVENT s20_heartbeat DISABLE;1회성 이벤트는 실행 후 기본으로 자동 삭제됩니다(ON COMPLETION NOT PRESERVE). 주의: 이벤트 스케줄러가OFF면 이벤트는 등록만 되고 절대 실행되지 않습니다. 배포 환경에서 자주 놓칩니다.
| 상황 | 이유 |
|---|---|
| 비즈니스 로직 대부분을 DB에 넣기 | 버전관리·테스트·코드리뷰·CI 가 애플리케이션 코드보다 훨씬 열악 |
| 복잡한 분기/문자열 처리 | SQL 절차 언어는 디버거가 빈약하고 표현력이 낮음 |
| 트리거로 핵심 로직 숨기기 | "보이지 않는 부작용" → 유지보수 재앙 (20-7 참고) |
| 수평 확장이 중요한 서비스 | 로직이 DB에 몰리면 DB 가 병목. 앱 서버는 늘려도 DB 는 늘리기 어려움 |
| 자주 바뀌는 로직 | 프로시저 배포는 앱 배포보다 롤백/카나리가 어려움 |
💡 현대적 균형점
- DB에 둘 것: 데이터 무결성 제약(FK/CHECK/UNIQUE), 감사 트리거 같은 "데이터에 밀착한 규칙", 대량 배치 집계
- 앱에 둘 것: 비즈니스 규칙, 분기 로직, 외부 연동 — 테스트와 버전관리가 되는 곳 트리거는 로깅/감사/무결성 보조처럼 "조용하고 단순한" 용도로 한정하고, 재고 차감 같은 핵심 로직까지 트리거에 넣는 것은 신중히 결정하세요. (이 스텝의 재고 차감 트리거는 학습용 예제입니다. 실무에선 앱의 트랜잭션 안에서 명시적으로 처리하는 편이 추적이 쉽습니다.)
| 객체 | 생성 | 호출/발동 | 특성 선언 |
|---|---|---|---|
| 함수(FUNCTION) | CREATE FUNCTION ... RETURNS t | SQL 안에서 f(x) | DETERMINISTIC/READS SQL DATA/NO SQL 필수 |
| 프로시저(PROCEDURE) | CREATE PROCEDURE | CALL p(...) | IN/OUT/INOUT |
| 트리거(TRIGGER) | CREATE TRIGGER ... BEFORE/AFTER | DML 시 자동 | OLD/NEW |
| 이벤트(EVENT) | CREATE EVENT ... ON SCHEDULE | 스케줄러가 자동 | event_scheduler=ON 필요 |
| 구문 | 용도 |
|---|---|
IF/CASE | 분기 |
WHILE/REPEAT/LOOP+LEAVE | 반복 |
DECLARE ... CURSOR + CONTINUE HANDLER FOR NOT FOUND | 행 단위 순회 |
SIGNAL SQLSTATE '45000' | 사용자 에러 던지기 |
DECLARE EXIT HANDLER FOR SQLEXCEPTION + GET DIAGNOSTICS | 에러 잡기 + 롤백 |
RESIGNAL | 잡은 에러 다시 던지기 |
exercise.sql 의 6문제를 풀어 보세요. 정답은 solution.sql.
Step 17~20 을 통해 MySQL 8 의 분석 함수(윈도우), 반정형 데이터(JSON), 동시성(트랜잭션·락), 서버측 프로그래밍(저장 프로그램)까지 마쳤습니다.
이 스텝은 SQL 스크립트 네 개로 진행합니다. 먼저 practice.sql 로 s20_ 사본 테이블과 함수·프로시저·트리거·이벤트를 한 번에 만들며 본문 20-1 ~ 20-8 을 따라가고, 그다음 exercise.sql 의 빈칸 6문제를 직접 채워 본 뒤 solution.sql 로 답을 맞춰 봅니다. 마지막에 cleanup.sql 로 만들어 둔 s20_ 객체를 전부 지웁니다. 네 스크립트 모두 DELIMITER 를 쓰므로 반드시 mysql 클라이언트로 실행해야 합니다 (GUI 툴이나 JDBC 로는 DELIMITER 가 통하지 않습니다).
본문 전체를 그대로 재현하는 메인 실습 스크립트입니다. mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop --table < practice.sql 로 실행하세요.
아래 대괄호 번호(
[20-0],[20-1]…)는 스크립트 안의 주석 구분자입니다. 본문 헤딩 번호와는 커서 절부터 한 칸씩 어긋나니 주의하세요. 예를 들어 스크립트의[20-5] 에러 처리는 본문 20-6 에 해당합니다.
INSERT INTO s20_products ... SELECT ... FROM products WHERE product_id <= 10 으로 공용 products 에서 10개만 복사합니다. 이후 모든 재고 차감·가격 변경은 이 사본에만 일어나므로 공용 테이블은 안전합니다. s20_audit(감사로그), s20_order_items(주문) 도 여기서 만듭니다. 스크립트 맨 앞의 DROP TABLE IF EXISTS 3줄 덕분에 여러 번 재실행해도 됩니다(단, 이전 실습 데이터는 날아갑니다).s20_margin_rate 는 DETERMINISTIC, 테이블을 읽는 s20_stock_of 는 READS SQL DATA 로 선언합니다. 이 선언을 지우면 이 서버(log_bin ON, log_bin_trust_function_creators=0)에서는 곧바로 ERROR 1418 이 납니다.s20_price_stats(OUT 2개), s20_add_bonus(INOUT), CASE 를 쓰는 s20_stock_grade, WHILE 반복 INSERT 인 s20_fill_numbers, LOOP+LEAVE 인 s20_factorial 을 만듭니다. s20_fill_numbers 는 임시 테이블 s20_nums 에 1행씩 넣는 일부러 비효율적인 예제로, 집합 SQL 로 바꿔야 한다는 본문 20-4 의 팁과 짝을 이룹니다.s20_stock_report 프로시저입니다. DECLARE 를 변수 → 커서 → 핸들러 순으로 쓰는 규칙, FETCH 직후에 v_done 을 검사해 LEAVE 하는 표준 패턴을 그대로 보여 줍니다. CALL s20_stock_report(); 로 10개 상품의 등급 요약 한 줄이 나옵니다.CALL s20_ship(1, 10); 과, s20_ship_safe 를 성공(2, 5)·실패(2, 99999)로 각각 한 번씩 부르는 줄입니다. 실패 호출은 EXIT HANDLER 가 잡아 롤백하므로 스크립트를 멈추지 않고 @ok=0 을 돌려줍니다. 반면 에러를 그대로 터뜨리는 세 줄(s20_ship(1, 99999), s20_ship(999, 1), s20_ship(1, 0))은 주석 처리되어 있습니다. 스크립트가 중간에 멈추지 않게 하려는 것이니, 에러 메시지를 직접 보려면 한 번에 하나씩 주석을 풀어 실행하세요.INSERT INTO s20_order_items(product_id, quantity) VALUES (5, 3); 한 줄이 AFTER INSERT → UPDATE s20_products → BEFORE UPDATE 로 연쇄하며 감사로그가 STOCK, ORDER 순서로 남는 것을 눈으로 확인하는 부분입니다. 트리거가 SIGNAL 로 작업을 막는 두 줄(수량 0 주문, 재고 -1 UPDATE)도 여기 있지만 주석 처리되어 있으니 필요할 때 풀어 보세요.s20_heartbeat(1분 주기)와 s20_once(5초 뒤 1회)를 만들고 SHOW EVENTS 로 확인한 뒤, s20_heartbeat 만 ALTER EVENT s20_heartbeat DISABLE; 로 끕니다. 그대로 두면 1분마다 감사로그가 계속 쌓이기 때문입니다. s20_once 는 끄지 않으므로 실행 5초 뒤 s20_audit 에 ONCE 행이 한 줄 남습니다(그리고 ON COMPLETION NOT PRESERVE 기본값에 따라 스스로 사라집니다). 두 이벤트의 최종 삭제는 cleanup.sql 의 몫입니다.information_schema.ROUTINES 와 information_schema.TRIGGERS 를 조회해 방금 만든 s20_ 루틴·트리거 목록을 확인합니다. IS_DETERMINISTIC 컬럼에서 어떤 함수가 DETERMINISTIC 으로 선언됐는지도 눈으로 볼 수 있습니다.연습문제 6개의 빈 껍데기입니다. 각 문제 아래 -- 여기에 작성 자리에 직접 코드를 채워 넣으세요.
practice.sql 을 먼저 실행해 s20_products / s20_audit / s20_order_items 가 있는 상태에서 풀어야 합니다. 문제 3·4·5 가 이 사본 테이블을 직접 건드리기 때문입니다.s20ex_ 접두사를 씁니다. practice.sql 이 만든 s20_ 객체와 이름이 겹치지 않도록 하기 위한 규칙입니다.DETERMINISTIC 선언을 빠뜨리면 ERROR 1418 이 납니다. 문제 3·6 은 SIGNAL SQLSTATE '45000', 문제 4 는 커서 + CONTINUE HANDLER FOR NOT FOUND, 문제 5 는 AFTER DELETE 트리거에서 OLD.quantity 를 쓰는 것이 핵심입니다.DELIMITER 를 쓰는 답안을 작성하게 되므로 mysql 클라이언트로 실행하세요.exercise.sql 6문제의 모범 답안과 해설입니다. 스스로 풀어 본 뒤에 비교하세요.
s20_products / s20_audit / s20_order_items 를 다시 만듭니다. 즉 이 파일을 실행하면 practice.sql 로 쌓아 둔 감사로그와 재고 변동이 전부 초기화됩니다. 순서에 주의하세요.s20ex_restock 은 UPDATE 직후 ROW_COUNT() = 0 으로 "존재하지 않는 상품"을 판별합니다. SELECT ... INTO 로 먼저 확인하는 방식보다 쿼리가 한 번 적습니다. 정답 6 s20ex_set_price 도 같은 패턴입니다.CONCAT_WS(', ', NULLIF(v_out,''), ...) 는 첫 항목 앞에 콤마가 붙지 않게 하는 관용구입니다. NULLIF 로 빈 문자열을 NULL 로 만들면 CONCAT_WS 가 그 자리를 건너뜁니다.s20ex_ad_order_items(AFTER DELETE) 뿐 아니라 s20ex_ai_order_items(AFTER INSERT) 도 같이 만듭니다. 그래야 상품 9의 재고가 200 → 195 → 200 으로 왕복하는 데모가 성립합니다. practice.sql 이 만든 s20_ai_order_items 와 이름이 겹쳐 재고가 이중 차감될 걱정은 없습니다 — 이 파일 맨 앞의 DROP TABLE s20_order_items 가 테이블과 함께 그 테이블의 트리거까지 지우기 때문입니다(MySQL 은 테이블을 DROP 할 때 딸린 트리거도 같이 DROP 합니다).s20ex_ 객체는 스스로 정리하지만, s20_ 공통 객체는 남깁니다. 그건 cleanup.sql 의 몫입니다.실습이 끝난 뒤 s20_ 로 시작하는 모든 것을 지우는 정리 스크립트입니다. mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop < cleanup.sql 로 실행합니다.
s20_heartbeat 이벤트를 먼저 지우지 않으면 테이블이 사라진 뒤에도 스케줄러가 깨어나 s20_audit 에 INSERT 를 시도하며 에러 로그를 남길 수 있습니다.DROP ... IF EXISTS 라서 일부만 만들어진 상태에서 실행해도 안전하고, 여러 번 돌려도 됩니다.DROP FUNCTION IF EXISTS s20_bad; 한 줄이 눈에 띌 텐데, 이는 20-2 의 함정(특성 선언 없이 함수를 만들어 ERROR 1418 을 재현해 보는 실험)을 직접 해 봤을 경우를 대비한 보험용 정리입니다. 실제로 그 함수는 생성 자체가 실패하므로 보통은 아무 일도 하지 않습니다.DROP TABLE 로 s20_products / s20_audit / s20_order_items 를 지우므로 실습 기록이 모두 사라집니다. 다만 공용 products 는 건드리지 않으니 다른 스텝에는 영향이 없습니다.