예외 처리를 빼먹으면 진짜 큰일 납니다
HANA SQLScript로 작성한 프로시저가 운영 환경에서 갑자기 멈추고, 수천 건의 SalesOrder가 절반만 처리된 채 롤백되는 상황을 본 적이 있나요? 이 글은 SQLScript의 예외 처리(Exception Handling) 메커니즘을 다루며, 특히 "예외 처리를 안 했을 때" 어떤 재앙이 벌어지는지에 초점을 맞춥니다. 끝까지 따라오면 다음을 익힐 수 있습니다.
- 예외 미처리 시 트랜잭션 전체 롤백이 일어나는 구조 이해
- EXIT HANDLER와 CONTINUE HANDLER의 동작 차이와 선택 기준
- SIGNAL / RESIGNAL로 사용자 정의 예외를 던지고 다시 던지는 방법
- SQL_ERROR_CODE, SQL_ERROR_MESSAGE를 활용한 진단 정보 수집
- SalesOrder 일괄 처리에서 부분 실패를 허용하는 패턴 설계
이 글을 읽기 전에 알아두면 좋은 것들
SQL DML(INSERT / UPDATE / DELETE)에 익숙하고, HANA에서 CREATE PROCEDURE 문법으로 스토어드 프로시저를 한 번이라도 작성해 본 경험이 있어야 합니다. 또한 BEGIN ... END 블록, DECLARE 변수, 트랜잭션 개념(COMMIT, ROLLBACK)을 알고 있다면 본문 내용을 훨씬 빠르게 흡수할 수 있습니다. ABAP CDS나 AMDP를 다뤄봤다면 더 유리합니다.
실습 환경 및 준비물
이 글의 예제는 일반적으로 SAP HANA Cloud(2025 QRC2 기준) 또는 HANA 2.0 SPS07 이상에서 검증되는 SQLScript 문법을 사용합니다. 클라이언트 도구는 SAP HANA Database Explorer 또는 SAP HANA Studio, VS Code의 SAP HANA Tools 확장 중 무엇을 써도 무방합니다.
- HANA DB: HANA Cloud 2025 또는 HANA 2.0 SPS07+
- 권한: 자신의 스키마에 CREATE PROCEDURE, CREATE TABLE 권한
- 샘플 테이블: SalesOrder(주문 헤더), OrderItem(주문 라인), InventoryStock(재고)
- 접속: SQL Console에 로그인 가능한 DB 사용자
CREATE COLUMN TABLE SalesOrder (
order_id NVARCHAR(10) PRIMARY KEY,
customer_id NVARCHAR(10) NOT NULL,
total_amount DECIMAL(15,2),
status NVARCHAR(20) DEFAULT 'NEW'
);
CREATE COLUMN TABLE OrderItem (
order_id NVARCHAR(10),
item_no INTEGER,
material NVARCHAR(18),
quantity INTEGER,
PRIMARY KEY (order_id, item_no)
);
CREATE COLUMN TABLE InventoryStock (
material NVARCHAR(18) PRIMARY KEY,
on_hand INTEGER NOT NULL
);
예외는 어떻게 흘러가는가 - 핵심 동작 원리
SQLScript에서 예외(Exception)는 "프로시저가 정상 경로를 벗어났음을 알리는 신호"입니다. 0으로 나누기, 유니크 제약 위반, NOT NULL 제약 위반, 명시적 SIGNAL 등이 모두 예외를 일으킵니다. 이 신호를 잡아주지 않으면 어떻게 될까요? 비유하자면, 공장 컨베이어 벨트에서 불량품이 하나 발견됐는데 라인 전체를 그대로 멈춰버리는 상황과 같습니다. 처리 중이던 SalesOrder 1,000건이 절반만 처리된 채 모두 롤백됩니다.
SQLScript 예외 전파는 다음과 같이 정리할 수 있습니다.
- 예외 발생 지점에서 가장 가까운 BEGIN ... END 블록을 검사
- 해당 블록에 DECLARE HANDLER가 있고 조건이 매치되면 핸들러 실행
- 매치되는 핸들러가 없으면 상위 블록으로 예외 전파
- 최상위까지 잡히지 않으면 프로시저 실패 + 암묵적 ROLLBACK
핸들러는 두 종류입니다. EXIT HANDLER는 예외 처리 후 해당 블록을 즉시 종료하고, CONTINUE HANDLER는 예외를 일으킨 문장 다음 줄부터 실행을 이어갑니다. 이 선택을 잘못하면 데이터 정합성이 무너집니다. 예를 들어 "재고 차감 실패 → 다음 라인 계속 처리"가 비즈니스적으로 허용되지 않는데 CONTINUE HANDLER를 쓰면, 일부 라인만 차감된 상태로 주문이 확정되는 끔찍한 상황이 됩니다.
[프로시저 시작]
|
v
[BEGIN 블록 진입]
|
+-- DML 1 (성공)
+-- DML 2 (예외 발생!) ----+
| |
| +--------------------+
| v
| [핸들러 검색]
| |
| +-- 매치 됨? --> EXIT: 블록 종료
| | CONTINUE: DML 3로 점프
| +-- 없음? -----> 상위로 전파 -> 결국 ROLLBACK
1단계 - 예외 처리를 안 하면 무슨 일이 벌어질까
먼저 핸들러 없이 작성된 프로시저를 봅시다. 신규 주문을 등록하면서 재고를 차감하는 가장 단순한 로직입니다.
CREATE OR REPLACE PROCEDURE PROC_PLACE_ORDER_NAIVE (
IN p_order_id NVARCHAR(10),
IN p_customer_id NVARCHAR(10),
IN p_material NVARCHAR(18),
IN p_quantity INTEGER
)
LANGUAGE SQLSCRIPT AS
BEGIN
INSERT INTO SalesOrder (order_id, customer_id, total_amount, status)
VALUES (:p_order_id, :p_customer_id, 0, 'NEW');
INSERT INTO OrderItem (order_id, item_no, material, quantity)
VALUES (:p_order_id, 10, :p_material, :p_quantity);
UPDATE InventoryStock
SET on_hand = on_hand - :p_quantity
WHERE material = :p_material;
END;
겉으로는 멀쩡해 보이죠. 그러나 다음 케이스에서 즉시 무너집니다.
- 동일한 order_id로 두 번 호출 → 첫 INSERT에서 PK 중복 예외 → 호출자에게 raw 오류 메시지가 그대로 노출
- 존재하지 않는 material → UPDATE 0건 (예외는 아니지만 재고 차감 누락이 감지되지 않음)
- 다중 호출을 묶은 상위 프로시저에서 예외 → 전체 트랜잭션 ROLLBACK
운영팀이 받는 메시지는 보통 이런 식입니다. 301: unique constraint violated: ... — 어떤 주문이 실패했는지, 무엇을 다시 해야 하는지 알 길이 없습니다.
2단계 - EXIT HANDLER와 진단 정보 수집
이제 핸들러를 추가해 봅시다. SQLSTATE, SQL_ERROR_CODE, SQL_ERROR_MESSAGE를 활용해 진단 정보를 잡고, 별도 로그 테이블에 기록한 후 사용자 친화적인 메시지로 다시 던집니다.
CREATE COLUMN TABLE OrderErrorLog (
log_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
order_id NVARCHAR(10),
err_code INTEGER,
err_state NVARCHAR(5),
err_message NVARCHAR(2000)
);
CREATE OR REPLACE PROCEDURE PROC_PLACE_ORDER_V2 (
IN p_order_id NVARCHAR(10),
IN p_customer_id NVARCHAR(10),
IN p_material NVARCHAR(18),
IN p_quantity INTEGER
)
LANGUAGE SQLSCRIPT AS
BEGIN
DECLARE v_err_code INTEGER;
DECLARE v_err_state NVARCHAR(5);
DECLARE v_err_message NVARCHAR(2000);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
SELECT ::SQL_ERROR_CODE, ::SQL_ERROR_MESSAGE
INTO v_err_code, v_err_message
FROM DUMMY;
INSERT INTO OrderErrorLog (order_id, err_code, err_message)
VALUES (:p_order_id, :v_err_code, :v_err_message);
SIGNAL SQL_ERROR_CODE 10001
SET MESSAGE_TEXT = 'Order processing failed - see OrderErrorLog';
END;
INSERT INTO SalesOrder (order_id, customer_id, total_amount, status)
VALUES (:p_order_id, :p_customer_id, 0, 'NEW');
INSERT INTO OrderItem (order_id, item_no, material, quantity)
VALUES (:p_order_id, 10, :p_material, :p_quantity);
UPDATE InventoryStock
SET on_hand = on_hand - :p_quantity
WHERE material = :p_material;
END;
핵심 포인트는 세 가지입니다. 첫째, EXIT HANDLER FOR SQLEXCEPTION은 어떤 SQL 예외든 잡아냅니다. 둘째, ::SQL_ERROR_CODE와 ::SQL_ERROR_MESSAGE는 핸들러 안에서만 의미가 있습니다. 셋째, 로그를 남긴 후 SIGNAL로 사용자 정의 코드(10001)를 다시 던져 호출자에게 "복구 가능한 비즈니스 실패"임을 알립니다.
주의: 핸들러 내부에서 INSERT한 OrderErrorLog는, 외부 트랜잭션이 롤백되면 함께 사라질 수 있습니다. 영구 로그가 필요하면 AUTONOMOUS TRANSACTION 블록을 사용해야 합니다.
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
DECLARE v_code INTEGER = ::SQL_ERROR_CODE;
DECLARE v_msg NVARCHAR(2000) = ::SQL_ERROR_MESSAGE;
BEGIN AUTONOMOUS TRANSACTION
INSERT INTO OrderErrorLog (order_id, err_code, err_message)
VALUES (:p_order_id, :v_code, :v_msg);
COMMIT;
END;
RESIGNAL;
END;
RESIGNAL은 원본 예외를 그대로 다시 던집니다. 로그만 남기고 호출자에게는 원래 오류를 전달하고 싶을 때 가장 적절합니다.
3단계 - 운영 등급: 부분 실패 허용 일괄 처리
실무에서 가장 까다로운 시나리오는 "여러 건을 한꺼번에 처리하되, 일부가 실패해도 나머지는 계속 진행해야 하는" 경우입니다. 예를 들어 야간 배치로 1,000건의 SalesOrder를 확정 처리할 때, 하나가 망가졌다고 나머지 999건을 멈출 수는 없죠. 이때 CONTINUE HANDLER와 내부 BEGIN 블록의 조합이 빛을 발합니다.
CREATE OR REPLACE PROCEDURE PROC_CONFIRM_ORDERS_BULK (
IN it_orders TABLE (order_id NVARCHAR(10)),
OUT ot_result TABLE (order_id NVARCHAR(10), status NVARCHAR(20), err_message NVARCHAR(2000))
)
LANGUAGE SQLSCRIPT
READS SQL DATA AS
BEGIN
DECLARE v_order_id NVARCHAR(10);
DECLARE v_total INTEGER;
DECLARE v_idx INTEGER DEFAULT 1;
DECLARE results TABLE (order_id NVARCHAR(10), status NVARCHAR(20), err_message NVARCHAR(2000));
SELECT COUNT(*) INTO v_total FROM :it_orders;
WHILE :v_idx <= :v_total DO
v_order_id := :it_orders.order_id[:v_idx];
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
DECLARE v_msg NVARCHAR(2000) = ::SQL_ERROR_MESSAGE;
:results.INSERT((:v_order_id, 'FAILED', :v_msg));
END;
UPDATE SalesOrder SET status = 'CONFIRMED'
WHERE order_id = :v_order_id AND status = 'NEW';
IF ::ROWCOUNT = 0 THEN
SIGNAL SQL_ERROR_CODE 10010
SET MESSAGE_TEXT = 'Order not found or already confirmed';
END IF;
:results.INSERT((:v_order_id, 'CONFIRMED', NULL));
END;
v_idx := :v_idx + 1;
END WHILE;
ot_result = SELECT * FROM :results;
END;
설계 의도를 풀어보면, 매 반복마다 내부 BEGIN ... END 블록을 만들고 그 안에 EXIT HANDLER를 둡니다. 한 주문이 실패하면 EXIT HANDLER가 내부 블록만 종료시키므로, WHILE 루프는 다음 반복을 계속 진행합니다. 성공/실패 결과는 results 테이블 변수에 누적되어 OUT 파라미터로 반환됩니다. 이 패턴은 ABAP의 TRY-CATCH를 루프 안에 두는 것과 동일한 효과를 냅니다.
추가로 운영 등급으로 끌어올리려면 다음을 고려하세요.
- 성능: WHILE 루프 대신 가능하면 집합 기반 처리(set-based)로 작성하고, 실패할 수 있는 행만 미리 EXISTS / LEFT JOIN으로 걸러내기
- 관측성: ApplicationFunctionLibrary 또는 m_sql_plan_cache와 별개로, 자체 OrderErrorLog 테이블에 trace_id를 추가해 분산 추적과 연결
- 보안: SIGNAL의 MESSAGE_TEXT에 민감 정보(고객 식별자, 내부 경로) 노출 금지
- 테스트: SQL Unit 또는 자체 케이스 테이블로 정상/이상/경계 입력을 자동 회귀
현장에서 자주 마주치는 실수와 FAQ
지금까지의 패턴을 적용하더라도 작은 실수 하나로 핸들러가 동작하지 않을 수 있습니다. 자주 보는 함정을 정리했습니다.
- DECLARE HANDLER 위치 오류: 핸들러는 반드시 변수 DECLARE 다음, 실행문 이전에 선언해야 합니다. 중간에 두면 컴파일 에러
- SQLWARNING과 NOT FOUND 미고려: SQLEXCEPTION만 잡으면 경고나 SELECT INTO의 NOT FOUND가 빠질 수 있음
- 핸들러 안에서 또 예외 발생: 핸들러 내부에서 INSERT가 실패하면 즉시 상위로 전파됨. 핸들러 코드는 가능한 단순하게
- CONTINUE HANDLER 남용: 부분 실패가 비즈니스적으로 허용되지 않는 트랜잭션에 쓰면 데이터 불일치 발생
FAQ 1. "프로시저 호출 후 일부만 반영됐는데 ROLLBACK이 안 됐어요. 왜죠?"
HANA는 프로시저 안에서 명시적 COMMIT/ROLLBACK을 제한적으로만 허용합니다. 호출자 세션의 autocommit 설정과 트랜잭션 경계를 함께 점검해야 합니다. 일반적으로 호출자 측에서 명시적 트랜잭션을 열고, 프로시저 종료 후 결과에 따라 COMMIT 또는 ROLLBACK을 결정하는 패턴을 권장합니다.
FAQ 2. "SIGNAL과 RESIGNAL은 정확히 어떻게 다른가요?"
SIGNAL은 새로운 예외를 만들어 던지고, RESIGNAL은 핸들러가 잡은 원본 예외를 그대로 다시 던집니다. 로깅 후 원본 오류를 보존하고 싶으면 RESIGNAL, 호출자에게 추상화된 비즈니스 오류를 주고 싶으면 SIGNAL을 선택하세요.
FAQ 3. "SQL_ERROR_CODE 값은 어떤 범위를 써야 안전한가요?"
사용자 정의 코드는 일반적으로 10000 이상을 사용하는 것이 권장됩니다. 1~9999 범위는 시스템 예약 영역과 충돌할 가능성이 있습니다. 프로젝트 단위로 코드 대역(예: 10000~10999는 주문, 11000~11999는 재고)을 문서화해 두세요.
이 다음에 살펴볼 주제들
예외 처리는 그 자체로 완결되지 않습니다. 다음 주제로 확장하면 견고한 데이터 계층을 설계할 수 있습니다.
- AUTONOMOUS TRANSACTION을 활용한 감사(audit) 로그 영속화
- Table Function vs Procedure 선택 기준과 예외 전파 차이
- AMDP(ABAP Managed Database Procedure)에서 CX_AMDP_ERROR로 변환되는 흐름
- SQLScript 디버거와 m_sql_plan_cache, m_expensive_statements를 활용한 사후 분석
- HANA Cloud의 PlanViz로 예외 발생 직전 실행 계획 검증
댓글 0
아직 댓글이 없습니다.