예외를 무시하면 생기는 실제 장애 사례
SAP HANA SQLScript에서 예외 처리를 생략한 채 프로시저를 운영에 올리면, 단순한 버그가 아니라 데이터 정합성 자체가 무너지는 사고로 이어집니다. 예를 들어 SalesOrder 헤더는 INSERT 됐지만 라인 아이템 INSERT 단계에서 제약 조건 위반이 발생했다고 가정해 봅시다. 트랜잭션 경계와 예외 처리가 명확히 설계돼 있지 않다면, 헤더만 남고 라인이 사라진 "고스트 주문(ghost order)"이 발생합니다. 회계 마감 때 합계가 어긋나고, 영업 담당자는 존재하지 않는 주문을 클레임 처리해야 하죠.
또 다른 패턴은 ETL 잡에서의 무성(silent) 실패입니다. 야간 배치가 1,200건 중 7건의 변환 오류를 만났는데 핸들러가 없어 전체가 ROLLBACK 되거나, 반대로 오류 코드가 로그에 남지 않아 잘못된 데이터가 다음 단계로 그대로 흘러갑니다. 다음과 같은 영역에서 특히 사고가 잦습니다.
- 외부 시스템에서 받은 ProductOrder의 통화 코드가 마스터에 없는 경우
- 대용량 INSERT...SELECT 도중 단 한 건의 NULL 제약 위반
- UDF 내부에서 0으로 나누기(divide-by-zero) 발생 시 NULL이 아닌 SQL 오류 22012
- 커밋되지 않은 트랜잭션이 락을 잡은 채 핸들러 없이 종료되는 경우
이 글은 위와 같은 사고를 예방하기 위해 SQLScript의 예외 처리 메커니즘을 깊이 있게 정리하고, Invoice 처리 시나리오로 끝까지 동작하는 실전 예제까지 구성합니다.
알아두면 좋은 배경 지식
이 글은 HANA Cloud 또는 HANA 2.0 SPS 06 이상을 기준으로 합니다. CREATE PROCEDURE, BEGIN ... END, DECLARE 변수 선언, 기본적인 IF/WHILE 흐름 제어를 알고 있다고 가정합니다. ABAP CDS나 AMDP에서 SQLScript를 호출해 본 경험이 있다면 더 좋습니다. HANA Studio, BAS(Business Application Studio), 또는 SAP HANA Database Explorer 중 하나로 프로시저를 실행할 수 있어야 하며, _SYS_BIC 스키마 또는 사용자 스키마에서 CREATE 권한이 필요합니다.
DECLARE HANDLER 구문의 핵심 구조
SQLScript의 예외 처리는 ANSI SQL/PSM 모델을 따르지만 SAP 고유 확장이 더해져 있습니다. 핸들러는 블록의 변수·커서 선언 영역 다음, 실행 구문 이전에만 선언할 수 있습니다. 기본 형식은 다음과 같습니다.
DECLARE { EXIT | CONTINUE } HANDLER FOR
{ SQLEXCEPTION
| SQLWARNING
| NOT FOUND
| SQL_ERROR_CODE <정수> [, ...]
| <사용자정의예외명> [, ...] }
BEGIN
-- 복구·로깅·재전파 로직
END;
비유하자면 핸들러는 블록 안에 설치한 "스프링클러"입니다. 화재(예외)가 감지되면 자동으로 작동하지만, 어느 층(블록)에 어떤 종류의 화재 감지기를 두느냐에 따라 반응이 달라집니다. 핵심 포인트는 세 가지입니다.
- 스코프: 핸들러는 선언된 BEGIN ... END 블록 안에서만 유효합니다. 중첩 블록의 내부 핸들러가 우선 매칭되고, 처리되지 않으면 바깥 블록으로 전파됩니다.
- 매칭 우선순위: 구체적인 SQL_ERROR_CODE 핸들러가 SQLEXCEPTION 핸들러보다 우선합니다. 사용자 정의 예외명은 더 우선 매칭됩니다.
- ::SQL_ERROR_CODE / ::SQL_ERROR_MESSAGE: 핸들러 내부에서 자동으로 채워지는 시스템 변수로, 로깅과 분기 결정에 필수입니다.
EXIT HANDLER와 CONTINUE HANDLER, 언제 무엇을 쓰나
이 둘의 차이는 단순히 "끝낼지 계속할지"가 아니라, 비즈니스 트랜잭션의 의미를 결정합니다.
- EXIT HANDLER: 예외 발생 시 핸들러 본문을 실행한 뒤 해당 블록을 즉시 종료합니다. "주문 한 건이 깨지면 전체를 무효화한다"는 원자적(atomic) 처리에 적합합니다.
- CONTINUE HANDLER: 핸들러 본문을 실행한 뒤 예외를 던진 구문의 다음 구문부터 실행을 이어갑니다. "1,000건 중 실패 건은 에러 테이블에 적재하고 나머지는 계속 처리한다"는 배치형 처리에 적합합니다.
흔한 오해는 CONTINUE HANDLER가 자동 재시도를 해준다는 생각인데, 실제로는 실패한 구문은 실행되지 않은 채 건너뛰어집니다. 재시도가 필요하면 루프와 재시도 카운터를 직접 구현해야 합니다.
SIGNAL과 RESIGNAL로 오류 전파하기
SQLScript에서는 SIGNAL로 새로운 예외를 발생시키고, RESIGNAL로 현재 핸들링 중인 예외를 상위 블록에 다시 던질 수 있습니다.
-- 새 예외 발생: SQL_ERROR_CODE는 10000 이상 사용자 영역 권장
SIGNAL SQL_ERROR_CODE 20101
SET MESSAGE_TEXT = 'Invoice 통화 코드가 마스터에 존재하지 않습니다';
-- 사용자 정의 예외 선언 후 SIGNAL
DECLARE invalid_currency CONDITION FOR SQL_ERROR_CODE 20101;
SIGNAL invalid_currency
SET MESSAGE_TEXT = 'Invoice 통화 코드 누락';
-- 핸들러 안에서 추가 로깅 후 그대로 상위로 던지기
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, ::SQL_ERROR_CODE, ::SQL_ERROR_MESSAGE);
RESIGNAL;
END;
SIGNAL과 RESIGNAL을 적절히 섞으면 "내부 모듈은 도메인 예외만 던지고, 상위 오케스트레이션 계층이 트랜잭션 단위로 묶어 처리한다"는 계층화된 에러 모델을 만들 수 있습니다.
사용자 정의 예외 선언과 활용
SQL_ERROR_CODE만으로는 의미가 명확하지 않습니다. 코드 가독성과 유지보수를 위해 CONDITION으로 의미 있는 이름을 부여하는 것을 권장합니다.
DECLARE order_not_found CONDITION FOR SQL_ERROR_CODE 20201;
DECLARE credit_limit_over CONDITION FOR SQL_ERROR_CODE 20202;
DECLARE stock_insufficient CONDITION FOR SQL_ERROR_CODE 20203;
이렇게 선언하면 SIGNAL 시 의미를 드러낼 수 있고, 핸들러에서도 코드 번호 대신 이름으로 매칭할 수 있어 도메인 언어와 코드가 가까워집니다.
중첩 블록에서의 예외 처리 흐름 추적
실전에서 가장 자주 헷갈리는 부분이 중첩 블록의 전파 순서입니다. 내부 블록에서 EXIT HANDLER가 처리한 예외는 외부 블록으로 전파되지 않습니다. RESIGNAL을 명시적으로 호출해야 외부로 올라갑니다. 반면 핸들러가 매칭되지 않으면 자동으로 외부 블록의 핸들러를 탐색합니다.
중첩 핸들러 설계 원칙: 가장 안쪽 블록에서는 좁고 구체적인 예외(SQL_ERROR_CODE 또는 도메인 CONDITION)를 처리하고, 바깥 블록에서는 SQLEXCEPTION을 캐치해 트랜잭션을 ROLLBACK하고 운영 로그를 남기는 안전망 역할을 맡기는 것이 일반적으로 권장됩니다.
SalesOrder 처리 프로시저 실전 예제 — 전체 예외 핸들링 구현
이제 위 개념을 모두 엮어 SalesOrder를 처리하는 프로시저를 단계별로 구성합니다.
1단계: 기본 골격과 사용자 정의 예외
CREATE OR REPLACE PROCEDURE SP_PROCESS_SALES_ORDER (
IN iv_order_id NVARCHAR(20),
IN iv_customer_id NVARCHAR(20),
IN iv_amount DECIMAL(15,2),
OUT ev_status NVARCHAR(20),
OUT ev_message NVARCHAR(200)
)
LANGUAGE SQLSCRIPT
SQL SECURITY INVOKER
AS
BEGIN
DECLARE lv_credit_used DECIMAL(15,2);
DECLARE lv_credit_max DECIMAL(15,2);
DECLARE order_duplicate CONDITION FOR SQL_ERROR_CODE 20301;
DECLARE customer_missing CONDITION FOR SQL_ERROR_CODE 20302;
DECLARE credit_limit_over CONDITION FOR SQL_ERROR_CODE 20303;
-- 중복 주문 검증
IF (SELECT COUNT(*) FROM Z_SALES_ORDER WHERE ORDER_ID = :iv_order_id) > 0 THEN
SIGNAL order_duplicate SET MESSAGE_TEXT = '이미 존재하는 주문 ID';
END IF;
SELECT CREDIT_USED, CREDIT_LIMIT
INTO lv_credit_used, lv_credit_max
FROM Z_CUSTOMER_MASTER
WHERE CUSTOMER_ID = :iv_customer_id;
IF :lv_credit_used + :iv_amount > :lv_credit_max THEN
SIGNAL credit_limit_over SET MESSAGE_TEXT = '신용 한도 초과';
END IF;
INSERT INTO Z_SALES_ORDER VALUES (:iv_order_id, :iv_customer_id, :iv_amount, 'NEW', CURRENT_UTCTIMESTAMP);
UPDATE Z_CUSTOMER_MASTER SET CREDIT_USED = CREDIT_USED + :iv_amount WHERE CUSTOMER_ID = :iv_customer_id;
ev_status := 'OK';
ev_message := '주문 처리 완료';
END;
2단계: 핸들러 추가 — 도메인별 메시지와 로깅
-- 변수 선언부 바로 아래에 추가
DECLARE EXIT HANDLER FOR order_duplicate
BEGIN
ev_status := 'DUPLICATE';
ev_message := '주문 ID 중복: ' || :iv_order_id;
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, 'SO_DUP', :iv_order_id, ::SQL_ERROR_MESSAGE);
END;
DECLARE EXIT HANDLER FOR credit_limit_over
BEGIN
ev_status := 'CREDIT_BLOCK';
ev_message := '신용 한도 초과 - 사용:' || :lv_credit_used || ' 한도:' || :lv_credit_max;
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, 'SO_CRED', :iv_customer_id, :ev_message);
END;
DECLARE EXIT HANDLER FOR NOT FOUND
BEGIN
ev_status := 'CUSTOMER_MISSING';
ev_message := '고객 마스터 없음: ' || :iv_customer_id;
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, 'SO_NOCUST', :iv_customer_id, :ev_message);
END;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ev_status := 'FATAL';
ev_message := 'CODE=' || ::SQL_ERROR_CODE || ' MSG=' || ::SQL_ERROR_MESSAGE;
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, 'SO_FATAL', :iv_order_id, :ev_message);
END;
3단계: 배치 호출 래퍼 — CONTINUE HANDLER로 부분 실패 허용
CREATE OR REPLACE PROCEDURE SP_PROCESS_SALES_ORDER_BATCH (
IN it_orders TABLE (ORDER_ID NVARCHAR(20), CUSTOMER_ID NVARCHAR(20), AMOUNT DECIMAL(15,2))
)
LANGUAGE SQLSCRIPT AS
BEGIN
DECLARE lv_status NVARCHAR(20);
DECLARE lv_message NVARCHAR(200);
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
BEGIN
INSERT INTO Z_ERROR_LOG VALUES (CURRENT_UTCTIMESTAMP, 'BATCH', NULL, ::SQL_ERROR_MESSAGE);
END;
FOR cur_row AS SELECT * FROM :it_orders DO
CALL SP_PROCESS_SALES_ORDER(cur_row.ORDER_ID, cur_row.CUSTOMER_ID, cur_row.AMOUNT, lv_status, lv_message);
INSERT INTO Z_BATCH_RESULT VALUES (cur_row.ORDER_ID, lv_status, lv_message, CURRENT_UTCTIMESTAMP);
END FOR;
END;
이 구조의 장점은 내부 프로시저가 EXIT HANDLER로 도메인 예외를 깔끔히 매핑해 OUT 파라미터로 돌려주고, 상위 배치는 CONTINUE HANDLER로 예기치 못한 SQLEXCEPTION만 흡수한다는 점입니다. 부분 실패는 Z_BATCH_RESULT에 상태로 남고, 전체 잡은 중단되지 않습니다.
운영 환경에서 자주 마주치는 SQLScript 예외 패턴
마지막으로 실제 운영에서 반복적으로 등장하는 함정과 대응 패턴을 정리합니다.
- SELECT INTO와 NOT FOUND 혼동: SELECT INTO는 0건이면 NOT FOUND, 2건 이상이면 SQL_ERROR_CODE 1326을 던집니다. NOT FOUND 핸들러만 두면 다건 케이스가 SQLEXCEPTION 안전망으로 떨어집니다. 두 케이스를 모두 명시적으로 처리하는 것을 권장합니다.
- READ-ONLY 프로시저의 핸들러: READS SQL DATA로 선언된 프로시저 안에서 핸들러가 Z_ERROR_LOG에 INSERT를 시도하면 컴파일 단계에서 막힙니다. 로깅은 호출 측 또는 별도 AUTONOMOUS TRANSACTION 프로시저에 위임해야 합니다.
- AUTONOMOUS TRANSACTION과 예외: 자율 트랜잭션 블록 내부에서 발생한 예외도 일반 블록처럼 핸들러로 처리되지만, 핸들러 안에서 COMMIT/ROLLBACK을 명확히 해야 잠금이 풀립니다.
- 커서 루프 중 예외: FOR 루프 안의 예외를 CONTINUE HANDLER로 잡으면 실패 건을 건너뛰지만, 잘못된 데이터가 누락되지 않도록 별도 dead-letter 테이블 패턴을 적용하는 것이 안전합니다.
- ::SQL_ERROR_MESSAGE 길이: 5000자 제한이 있어 메시지를 그대로 NVARCHAR(200) 같은 짧은 컬럼에 INSERT하면 잘립니다. LEFT(::SQL_ERROR_MESSAGE, 200) 식으로 명시적 자르기를 권장합니다.
- SQL_ERROR_CODE 범위: 사용자 정의는 10000 이상이 일반적으로 권장되며, 시스템 예약 범위와의 충돌을 피하기 위해 조직 내부 표준 코드 체계(예: 20000~29999 도메인별 할당)를 세워 두면 좋습니다.
- FAQ 1 — RESIGNAL과 SIGNAL 차이? RESIGNAL은 현재 핸들링 중인 예외 컨텍스트(코드·메시지)를 유지한 채 다시 던지고, SIGNAL은 완전히 새로운 예외를 발생시킵니다. 로그 손실을 막으려면 핸들러 안에서는 RESIGNAL이 안전합니다.
- FAQ 2 — 핸들러에서 ROLLBACK 가능한가? 가능합니다. 단 호출 컨텍스트가 트랜잭션 제어를 허용해야 하며, AMDP처럼 ABAP에서 호출되는 경우 외부에서 트랜잭션을 관리하므로 내부 ROLLBACK은 피하는 것이 일반적입니다.
- FAQ 3 — 핸들러 안의 예외는 어떻게 되나? 핸들러 본문에서 또 예외가 발생하면 동일 블록의 다른 핸들러는 매칭되지 않고 즉시 상위 블록으로 전파됩니다. 따라서 핸들러 안의 INSERT 같은 보조 작업은 가능한 한 실패 위험이 낮은 형태로 작성하고, 추가 try-block을 BEGIN ... END로 감싸는 패턴이 권장됩니다.
이어서 살펴보면 좋은 주제
예외 처리를 익혔다면 다음 단계로 트랜잭션 격리 수준과 락 타임아웃 핸들링, AMDP에서의 예외 매핑(CX_AMDP_ERROR 계열), HANA Cloud의 audit policy와 결합한 보안 이벤트 로깅, 그리고 SQLScript 디버거를 활용한 예외 발생 시점 추적으로 학습 범위를 넓히는 것을 권장합니다. 특히 RAP(ABAP RESTful Application Programming Model) 환경에서 AMDP 메서드를 호출할 때, SQLScript 예외가 ABAP 측에 어떻게 전달되는지 이해하면 풀스택 오류 처리 설계가 훨씬 견고해집니다.
더 깊이 공부할 수 있는 자료
댓글 0
아직 댓글이 없습니다.