MySQL 스토어드 프로시저와 함수 — 서버 사이드 로직 구현

MySQL 스토어드 프로시저와 스토어드 함수의 차이, 파라미터 모드(IN/OUT/INOUT), 제어 흐름(IF/LOOP/CURSOR), 예외 처리, DETERMINISTIC 선언의 의미를 실전 예시로 설명합니다.

· 6 min read · PALDYN Team

지난 글에서 파생 테이블 병합 최적화를 살펴봤습니다. 이번 글에서는 MySQL에서 복잡한 비즈니스 로직을 서버에 저장해 재사용하는 **스토어드 루틴(프로시저·함수)**을 다룹니다.

스토어드 루틴이란

스토어드 루틴은 SQL과 절차적 로직을 묶어 데이터베이스 서버에 저장해 두고 이름으로 호출하는 코드 단위입니다. 애플리케이션이 매번 SQL을 전송하는 대신 CALL proc_name(args) 하나로 복잡한 처리를 서버에서 완결할 수 있습니다.

-- 루틴 목록 확인
SHOW PROCEDURE STATUS WHERE Db = 'mydb';
SHOW FUNCTION STATUS WHERE Db = 'mydb';

-- 루틴 정의 확인
SHOW CREATE PROCEDURE transfer_points\G
SHOW CREATE FUNCTION tier_label\G

DELIMITER 설정

MySQL 클라이언트는 ;을 명령 구분자로 사용합니다. 루틴 본문에도 ;이 있으므로 구분자를 임시 변경해야 합니다.

DELIMITER //

CREATE PROCEDURE my_proc()
BEGIN
  SELECT 1;  -- 이 ; 는 명령 끝이 아님
END //

DELIMITER ;

// 외에 $$를 쓰기도 합니다. 루틴 정의가 끝나면 반드시 원래 ;로 복원합니다.

프로시저 vs 함수

프로시저와 함수 비교

두 가지의 결정적 차이는 반환 방식DML 허용 여부입니다. 프로시저는 INSERT, UPDATE, DELETE, 트랜잭션 제어까지 모두 가능하고, CALL로 호출합니다. 함수는 SELECT 식 어디서나 사용할 수 있는 단일 스칼라값을 반환하지만 DML은 기본적으로 금지됩니다.

파라미터 모드 (프로시저)

DELIMITER //
CREATE PROCEDURE calc_discount(
  IN  p_price    DECIMAL(10,2),   -- 호출자→루틴 (읽기 전용)
  IN  p_rate     DECIMAL(5,2),
  OUT p_discount DECIMAL(10,2),   -- 루틴→호출자 (반환)
  INOUT p_total  DECIMAL(10,2)    -- 양방향
)
BEGIN
  SET p_discount = p_price * p_rate / 100;
  SET p_total    = p_total - p_discount;
END //
DELIMITER ;

-- 호출
SET @total = 50000;
CALL calc_discount(50000, 10.0, @disc, @total);
SELECT @disc, @total;

OUT/INOUT 인수는 반드시 사용자 변수(@이름)로 전달합니다.

스토어드 함수 — DETERMINISTIC 선언

프로시저·함수 코드 예시

DELIMITER //
CREATE FUNCTION vat_amount(price DECIMAL(10,2))
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
  RETURN price * 0.10;
END //
DELIMITER ;

-- WHERE 절에서도 직접 사용 가능
SELECT id, price, vat_amount(price) vat
FROM products
WHERE vat_amount(price) > 5000;

DETERMINISTIC은 같은 입력에 항상 같은 결과를 반환함을 선언합니다. 바이너리 로그 복제 환경에서 함수가 DETERMINISTIC이 아니면 MySQL이 복제 안전성 문제로 에러를 냅니다(log_bin_trust_function_creators 설정으로 우회 가능하지만 권장하지 않음).

제어 흐름

-- IF / ELSEIF / ELSE
IF p_score >= 90 THEN
  SET v_grade = 'A';
ELSEIF p_score >= 80 THEN
  SET v_grade = 'B';
ELSE
  SET v_grade = 'F';
END IF;

-- WHILE 루프
SET i = 1;
WHILE i <= 10 DO
  INSERT INTO log_table VALUES (i, NOW());
  SET i = i + 1;
END WHILE;

-- LOOP + LEAVE (break)
my_loop: LOOP
  FETCH cur INTO v_id;
  IF done THEN LEAVE my_loop; END IF;
END LOOP my_loop;

커서 — 결과셋 행 단위 처리

DELIMITER //
CREATE PROCEDURE process_overdue()
BEGIN
  DECLARE done INT DEFAULT 0;
  DECLARE v_id INT;
  DECLARE v_amount DECIMAL(10,2);

  DECLARE cur CURSOR FOR
    SELECT id, amount FROM orders WHERE due_date < CURDATE();
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;

  OPEN cur;
  row_loop: LOOP
    FETCH cur INTO v_id, v_amount;
    IF done THEN LEAVE row_loop; END IF;
    UPDATE orders SET penalty = v_amount * 0.05 WHERE id = v_id;
  END LOOP row_loop;
  CLOSE cur;
END //
DELIMITER ;

커서 루프는 행 단위 처리라 대량 데이터에는 느립니다. 가능하면 UPDATE ... JOIN 등 집합 기반 쿼리로 대체합니다.

예외 처리 — HANDLER

DELIMITER //
CREATE PROCEDURE safe_insert(IN p_id INT, IN p_name VARCHAR(50))
BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '삽입 실패';
  END;

  START TRANSACTION;
  INSERT INTO members (id, name) VALUES (p_id, p_name);
  COMMIT;
END //
DELIMITER ;

EXIT HANDLER는 예외 발생 시 루틴을 종료합니다. CONTINUE HANDLER는 예외 발생 후 다음 문장을 계속 실행합니다.

루틴 관리

-- 수정: DROP 후 재생성 (ALTER는 속성만 변경)
DROP PROCEDURE IF EXISTS transfer_points;

-- 권한 부여 (특정 루틴만 실행 허용)
GRANT EXECUTE ON PROCEDURE mydb.transfer_points TO 'appuser'@'%';
GRANT EXECUTE ON FUNCTION  mydb.tier_label TO 'appuser'@'%';

스토어드 루틴은 비즈니스 로직 중 변경 빈도가 낮고, 여러 애플리케이션이 공유하며, 트랜잭션이 포함된 작업에 적합합니다. 로직이 자주 바뀌거나 애플리케이션 단 테스트가 필요하다면 애플리케이션 레이어에 두는 것이 더 유연합니다.


지난 글: MySQL Derived Table Merge — 파생 테이블 병합 최적화

다음 글: MySQL 트리거 — 자동 감사와 무결성 보호


읽어주셔서 감사합니다. 😊