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 트리거 — 자동 감사와 무결성 보호


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