저장 프로시저 기초: “포인트 사용”을 DB에서 하나의 호출로 처리하기
지금까지는 애플리케이션이 여러 SQL을 순서대로 실행한다는 관점으로 진행했습니다. 그런데 포인트 사용처럼 “항상 같은 흐름”을 따르는 작업은, DB에 로직을 저장해 하나의 호출로 실행하는 방식도 많이 사용합니다. 이때 등장하는 것이 저장 프로시저(Stored Procedure)입니다.
저장 프로시저는 “자주 쓰는 SQL 묶음”을 DB 안에 저장해두고, 필요할 때 CALL로 호출하는 형태입니다. 장점은 흐름이 한 곳에 고정된다는 점이고, 단점은 로직이 DB에 묶이면서 운영/배포/테스트 전략이 필요해진다는 점입니다.
이 단원의 목적: 핵심 작업(포인트 사용)을 “단일 진입점”으로 만들기
포인트 사용은 멱등성/동시성/원장 기록/상태 관리가 같이 얽히는 대표 작업입니다. 프로시저로 만들면 다음 같은 효과를 기대할 수 있습니다.
- 처리 흐름(선점 → 차감 → 기록 → 완료)이 한 곳에 모인다
- 누가 호출하든(웹/앱/배치) 같은 규칙으로 처리된다
- 실수로 순서를 바꾸거나 일부 단계를 누락할 위험이 줄어든다
이번 글에서는 “요청 ID 선점 테이블(point_requests)”과 “잔액 테이블(point_balance)”을 사용하는 흐름을 기반으로, 포인트 사용 프로시저를 구성하는 예시를 보여줍니다.
1) 준비: 필요한 테이블/제약조건 요약
프로시저 예시는 아래 전제를 둡니다.
- point_requests: (uid, request_id) UNIQUE로 중복 요청 차단
- point_balance: uid PK, balance 컬럼에 현재 잔액 저장
- point_history: 원장 기록, hide=0 기본
그리고 포인트 사용은 “조건부 UPDATE(잔액 충분할 때만 차감)” 패턴을 사용합니다.
2) 프로시저 설계: 입력값과 출력값을 먼저 정한다
프로시저는 “인자”로 입력을 받고, 필요하면 결과를 출력합니다. 포인트 사용 프로시저라면 최소 입력은 다음이 필요합니다.
- p_uid: 사용자
- p_request_id: 멱등 키(중복 요청 차단)
- p_amount: 사용 금액(양수로 받는다고 가정)
- p_ref_id: 주문번호 등 추적용
출력은 보통 “성공/실패 코드” 하나면 충분합니다. 여기서는 p_result_code를 OUT 파라미터로 두겠습니다.
- 0: 성공
- 1: 잔액 부족
- 2: 중복 요청
- 9: 기타 오류
3) 프로시저 구현 예시(MySQL 계열 문법)
아래는 “요청 선점 → 잔액 조건부 차감 → 원장 기록 → 요청 DONE”을 한 번에 수행하는 예시입니다. 실제 운영에서는 오류 처리/로그/락 정책이 더 들어가지만, 학습 단계에서는 흐름이 보이게 구성하는 것이 먼저입니다.
DELIMITER $$ CREATE PROCEDURE sp_use_points( IN p_uid CHAR(36), IN p_request_id CHAR(36), IN p_amount INT, IN p_ref_id VARCHAR(64), OUT p_result_code INT ) proc: BEGIN DECLARE v_rows INT DEFAULT 0; -- 기본값: 기타 오류 SET p_result_code = 9; START TRANSACTION; -- 1) 요청 선점(중복이면 UNIQUE 위반) -- 중복 요청을 코드로 구분하기 위해 INSERT 성공 여부를 활용 -- (실무에서는 핸들러로 오류 코드를 더 세밀하게 처리하기도 함) INSERT INTO point_requests (uid, request_id, request_type, amount, ref_id, status, created_at, updated_at) VALUES (p_uid, p_request_id, 'USE', p_amount, p_ref_id, 'RESERVED', NOW(), NOW()); -- 2) 잔액 조건부 차감 UPDATE point_balance SET balance = balance - p_amount, updated_at = NOW() WHERE uid = p_uid AND balance >= p_amount; SET v_rows = ROW_COUNT(); -- 잔액 부족(차감 실패) IF v_rows = 0 THEN -- 요청 상태를 FAILED로 남길지 여부는 정책이지만, 여기서는 남기는 예시 UPDATE point_requests SET status = 'FAILED', updated_at = NOW() WHERE uid = p_uid AND request_id = p_request_id; COMMIT; SET p_result_code = 1; LEAVE proc; END IF; -- 3) 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (p_uid, 'FREE', 'USE', -p_amount, p_ref_id, CONCAT('요청ID:', p_request_id), NOW()); -- 4) 요청 완료 UPDATE point_requests SET status = 'DONE', updated_at = NOW() WHERE uid = p_uid AND request_id = p_request_id; COMMIT; SET p_result_code = 0; END$$ DELIMITER ; 이 프로시저는 “성공 여부”를 OUT 파라미터로 돌려줍니다. 호출 측에서는 결과 코드에 따라 화면/응답을 결정할 수 있습니다.
4) 호출 예시: CALL 한 번으로 처리
SET @result := -1;
CALL sp_use_points(
'11111111-1111-1111-1111-111111111111',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
150,
'ORDER_9001',
@result
);
SELECT @result AS result_code;
result_code가 0이면 성공, 1이면 잔액 부족입니다. 그리고 중복 요청은 이 예시 코드만으로는 “오류”로 떨어질 수 있어, 다음 섹션에서 중복을 어떻게 코드로 분리하는지 다룹니다.
5) 중복 요청(UNIQUE 위반)을 결과 코드로 분리하려면
(uid, request_id)에 UNIQUE가 걸려 있으므로, 같은 요청이 다시 들어오면 point_requests INSERT에서 실패합니다.
이 경우를 result_code=2로 돌리고 싶다면, “에러 핸들러(DECLARE ... HANDLER)”를 이용해 예외를 잡는 방식이 흔합니다. 문법이 낯설 수 있어, 핵심 아이디어만 잡아봅니다.
- UNIQUE 위반이 발생하면 트랜잭션을 롤백하거나, 이미 처리된 상태를 조회한다
- point_requests에서 status가 DONE이면 2(중복)로 응답할 수 있다
학습 단계에서는 “중복은 DB에서 막힌다”는 사실과, “그 막힘을 결과 코드로 다듬을 수 있다” 정도만 잡아도 충분합니다.
6) 프로시저 방식의 장단점(현실적인 관점)
장점
- 규칙이 한 곳에 고정: 호출자가 달라도 같은 흐름
- 실수 방지: 차감만 하고 원장 기록을 빼먹는 등 누락 위험 감소
- 트랜잭션이 자연스럽다: BEGIN~COMMIT 흐름을 DB 내부에서 강제
단점
- 테스트/배포 전략이 필요: 애플리케이션 코드처럼 쉽게 CI로 돌리기 어렵다
- DB 종속성: DBMS 변경 시 이식 비용이 커질 수 있다
- 로직이 커지면 유지보수가 어려워질 수 있음: 역할 분리가 중요
그래서 저장 프로시저는 “전부 DB로”가 아니라, 정말 핵심이면서 반복되는 작업을 작은 단위로 고정하는 용도로 쓰는 경우가 많습니다.
7) 간단 검증: 프로시저 실행 후 확인 쿼리
SET @uid := '11111111-1111-1111-1111-111111111111'; SET @request_id := 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
-- 요청 상태
SELECT status, amount, ref_id, created_at, updated_at
FROM point_requests
WHERE uid = @uid AND request_id = @request_id;
-- 잔액
SELECT balance, updated_at
FROM point_balance
WHERE uid = @uid;
-- 원장(요청ID로 추적)
SELECT id, action_type, amount, ref_id, memo, created_at
FROM point_history
WHERE uid = @uid
AND memo LIKE CONCAT('%', @request_id, '%')
ORDER BY created_at DESC, id DESC
LIMIT 10;
이 세 가지를 함께 보면 “요청이 어떤 상태로 끝났는지”를 빠르게 확인할 수 있습니다.
다음 글 예고: 에러 처리/검증 쿼리 — “원장 합계 vs balance” 불일치를 찾는 점검 SQL 만들기
프로시저까지 도입하면 처리 흐름은 단단해지지만, 운영에서 완벽한 시스템은 없기 때문에 “점검”이 필요합니다. 다음 글에서는 원장 합계(SUM)와 balance 값이 다르면 바로 찾아낼 수 있는 점검 쿼리, 그리고 어떤 유형의 불일치가 발생할 수 있는지(중간 실패, 수동 정정, 잘못된 숨김 처리)를 정리합니다.
댓글
댓글 쓰기