ABAP

RAISE EXCEPTION MESSAGE 없이 던지면? 로그가 빈다 #shorts #SAP #ABAP

▶ YouTube에서 보기

이 글이 답하는 질문

  • 예외 클래스를 만들었는데 운영 로그에 메시지가 비어 있는 이유는 무엇인가?
  • RAISE EXCEPTION TYPERAISE EXCEPTION NEW는 무엇이 다른가?
  • 예외 클래스 계층을 어디까지 나누고, 어디서 합쳐야 하는가?
  • 호출자가 복구할 수 있는 예외와 그냥 죽어야 하는 예외를 어떻게 가르는가?
  • 예외를 잡아서 로그만 남기는 코드가 왜 운영에서 가장 위험한가?

왜 예외 설계에서 자꾸 같은 실수가 반복되나

클래스 기반 예외는 문법이 어렵지 않습니다. TRY로 감싸고 CATCH로 받으면 되고, 던질 때는 RAISE EXCEPTION 한 줄이면 됩니다. 그래서 대부분 문법을 배우는 데 30분도 안 걸립니다. 문제는 그다음입니다. 예외는 정상 흐름이 아니라 실패 흐름을 설계하는 일이라 테스트에서 잘 드러나지 않습니다. 개발 중에는 데이터가 깨끗해서 예외 경로를 한 번도 안 타보고 이관되는 경우가 흔합니다.

그러다 운영에서 처음으로 예외 경로가 열립니다. 그때 나오는 증상은 대개 셋 중 하나입니다. 첫째, 사용자 화면에는 "처리 중 오류가 발생했습니다"만 뜨고 무엇이 잘못됐는지 아무 정보가 없습니다. 둘째, 로그에는 예외 클래스 이름만 남고 메시지가 비어 있습니다. 셋째, 아무 오류도 안 나는데 데이터가 일부만 들어가 있습니다. 셋 다 문법 오류가 아니라 설계 선택의 결과입니다.

이 글에서는 설비 점검 요청을 등록하는 사내 프로그램을 예로 듭니다. 점검 요청을 만들 때 설비 코드가 유효한지, 요청자가 그 공장에 권한이 있는지, 예상 비용이 한도를 넘지 않는지 검사하고, 하나라도 어긋나면 예외를 던지는 구조입니다. 이 흔한 시나리오 안에서 설계 실수가 어떻게 쌓이는지 단계별로 보겠습니다.

사전 가정

  • TRY / CATCH / RAISE EXCEPTION의 기본 문법을 알고 있다
  • 클래스와 인터페이스를 직접 만들어 본 경험이 있다
  • 메시지 클래스에 메시지를 등록해 본 적이 있다

실수 1 — 예외에 메시지를 붙이지 않는다

가장 자주 보는 형태입니다. 예외 클래스를 만들고 그냥 던집니다.

" 나중에 로그를 보면 아무것도 알 수 없는 코드
IF lv_equip_id IS INITIAL.
  RAISE EXCEPTION TYPE zcx_insp_request.
ENDIF.

이 코드는 잘 동작합니다. 예외도 정상적으로 전파됩니다. 그런데 호출자가 CATCH에서 lx->get_text( )를 부르면 클래스 이름 수준의 기본 문구만 돌아옵니다. 어떤 설비였는지, 어느 시점이었는지, 왜 막혔는지가 전부 사라집니다.

메시지 클래스를 연결하려면 예외 클래스가 IF_T100_MESSAGE를 구현해야 하고, 던질 때 어떤 메시지를 쓸지 지정해야 합니다.

CLASS zcx_insp_request DEFINITION
  PUBLIC INHERITING FROM cx_static_check FINAL CREATE PUBLIC.

  PUBLIC SECTION.
    INTERFACES if_t100_message.

    CONSTANTS:
      BEGIN OF equip_not_found,
        msgid TYPE symsgid VALUE 'ZINSP',
        msgno TYPE symsgno VALUE '011',
        attr1 TYPE scx_attrname VALUE 'MV_EQUIP_ID',
        attr2 TYPE scx_attrname VALUE '',
        attr3 TYPE scx_attrname VALUE '',
        attr4 TYPE scx_attrname VALUE '',
      END OF equip_not_found.

    DATA mv_equip_id TYPE zinsp_equip_id READ-ONLY.

    METHODS constructor
      IMPORTING textid   LIKE if_t100_message=>t100key OPTIONAL
                previous LIKE previous                 OPTIONAL
                iv_equip_id TYPE zinsp_equip_id        OPTIONAL.
ENDCLASS.

던지는 쪽은 이렇게 바뀝니다.

IF lv_equip_id IS INITIAL.
  RAISE EXCEPTION TYPE zcx_insp_request
    EXPORTING textid      = zcx_insp_request=>equip_not_found
              iv_equip_id = lv_equip_id.
ENDIF.

차이는 한 줄이지만 결과는 완전히 다릅니다. 이제 get_text( )가 "설비 &1 을(를) 찾을 수 없습니다"처럼 값이 채워진 문장을 돌려주고, 그 문장이 화면·로그·인터페이스 응답에 그대로 실립니다. 예외 객체는 오류를 전달하는 그릇이지, 오류가 무엇인지 설명해 주는 존재가 아닙니다. 설명은 넣어야 나옵니다.

실수 2 — 정적 검사와 동적 검사를 아무렇게나 고른다

예외 클래스를 만들 때 상위 클래스를 무엇으로 둘지 고르게 됩니다. 여기서 cx_static_checkcx_dynamic_check, cx_no_check 중 하나를 무심코 고르면 나중에 호출 규약이 흔들립니다.

정적 검사 계열은 메서드 시그니처에 RAISING으로 선언해야 하고, 호출자는 잡거나 다시 선언해야 합니다. 컴파일 시점에 강제되므로 호출자가 반드시 알아야 하는 실패에 적합합니다. 설비 코드가 없다, 권한이 없다, 한도를 넘었다 같은 업무 규칙 위반이 여기에 들어갑니다.

동적 검사 계열은 선언하지 않아도 컴파일이 통과합니다. 프로그래밍 실수처럼 정상 상황에서는 발생하지 않아야 하는 조건에 씁니다. 배열 인덱스 계산이 어긋난 경우 같은 것입니다.

검사 없음 계열은 아예 선언도 못 하게 막습니다. 시스템 수준에서 회복 불가능한 상황에 쓰는 것이라 업무 로직에서 직접 만들 일은 드뭅니다.

실무에서 가장 흔한 사고는 업무 규칙 위반을 동적 검사로 만들어 두는 것입니다. 그러면 호출자는 그 메서드가 실패할 수 있다는 사실 자체를 모릅니다. 컴파일러가 알려주지 않으니 TRY도 안 걸고, 결국 화면 최상단에서 덤프로 끝납니다. 업무 규칙 위반은 정적 검사, 프로그램 결함은 동적 검사가 기본선입니다.

실수 3 — 원인 예외를 버린다

계층이 있는 프로그램에서는 아래 계층의 예외를 위 계층 예외로 바꿔 던지는 일이 잦습니다. 이때 원인을 안 넘기면 추적이 그 자리에서 끊깁니다.

" 원인이 사라지는 코드
TRY.
    lo_cost_api->calculate( ).
  CATCH zcx_cost_api.
    RAISE EXCEPTION TYPE zcx_insp_request
      EXPORTING textid = zcx_insp_request=>cost_calc_failed.
ENDTRY.

위 코드는 "비용 계산에 실패했습니다"까지만 알려줍니다. 실제로는 환율 테이블이 비어 있었는지, 통화 코드가 틀렸는지, 원가 요소가 안 잡혔는지가 아래 예외에 들어 있었는데 그 정보를 버린 것입니다.

" 원인을 이어 붙이는 코드
TRY.
    lo_cost_api->calculate( ).
  CATCH zcx_cost_api INTO DATA(lx_cost).
    RAISE EXCEPTION TYPE zcx_insp_request
      EXPORTING textid   = zcx_insp_request=>cost_calc_failed
                previous = lx_cost.
ENDTRY.

previous를 넘기면 예외가 사슬로 연결됩니다. 로그에서 사슬을 따라 내려가면 최초 원인까지 도달할 수 있습니다. 운영 장애 분석에서 이 한 줄이 있고 없고의 차이는 보통 몇 시간입니다.

실수 4 — 예외 클래스를 필요 이상으로 쪼갠다

반대 방향의 실수도 있습니다. 오류 상황마다 예외 클래스를 하나씩 만드는 것입니다. 설비 없음, 권한 없음, 한도 초과, 상태 불일치, 중복 요청까지 각각 클래스를 만들면 호출부가 이렇게 됩니다.

TRY.
    lo_service->create_request( ).
  CATCH zcx_equip_not_found.
  CATCH zcx_no_authority.
  CATCH zcx_limit_exceeded.
  CATCH zcx_invalid_status.
  CATCH zcx_duplicate_request.
ENDTRY.

호출자가 이 다섯 가지에 서로 다르게 반응한다면 나쁘지 않은 설계입니다. 그런데 대부분은 다섯 개 CATCH가 전부 같은 처리를 합니다. 메시지를 화면에 띄우고 끝입니다. 그렇다면 클래스를 쪼갠 대가만 남고 얻는 것이 없습니다.

판단 기준은 단순합니다. 호출자가 다르게 처리할 것인가. 다르게 처리하지 않을 오류는 한 클래스로 묶고 메시지 상수로 구분하면 됩니다. 반대로 처리 방식이 갈리는 오류는 반드시 나눠야 합니다. 예를 들어 "한도 초과"는 승인 요청으로 넘어가고 "권한 없음"은 즉시 중단이라면, 이 둘은 호출부에서 갈리므로 분리할 값어치가 있습니다.

실수 5 — 잡아서 로그만 남기고 계속 진행한다

운영에서 가장 위험한 형태입니다.

" 실패를 삼키는 코드
TRY.
    lo_service->create_request( ).
  CATCH zcx_insp_request INTO DATA(lx).
    " 일단 로그만 남기고 넘어가자
    MESSAGE lx TYPE 'S'.
ENDTRY.

" 아래는 요청이 만들어졌다는 전제로 진행된다
lo_notifier->send_to_planner( lv_request_id ).

예외를 잡아 화면 메시지로 흘려보내면 프로그램은 계속 흐릅니다. 요청은 만들어지지 않았는데 후속 로직은 만들어졌다고 가정하고 진행합니다. 알림은 빈 번호로 나가고, 집계는 어긋나고, 사용자는 성공한 줄 압니다.

예외를 잡았으면 셋 중 하나를 해야 합니다. 복구하거나, 다시 던지거나, 흐름을 멈추거나입니다. 로그만 남기고 아래로 흘려보내는 선택지는 없습니다. 굳이 계속 진행해야 한다면, 진행해도 되는 이유를 주석이 아니라 코드로 표현해야 합니다. 예를 들어 결과를 상태 값으로 바꿔 후속 로직이 분기하게 만드는 식입니다.

실수 6 — 트랜잭션 경계를 예외 처리와 따로 생각한다

예외를 잡은 지점과 데이터베이스 변경을 되돌리는 지점이 어긋나면, 오류는 정상적으로 보고됐는데 데이터는 일부만 남습니다.

" 헤더는 등록되고 항목에서 실패한 경우
lo_header->insert( ).            " 성공

TRY.
    lo_item->insert_all( ).      " 실패
  CATCH zcx_insp_request INTO DATA(lx).
    " 여기서 되돌리지 않으면 헤더만 남는다
    ROLLBACK WORK.
    RAISE EXCEPTION lx.
ENDTRY.

주의할 점이 하나 더 있습니다. 되돌리는 책임을 아래 계층에 두면 상위 트랜잭션까지 함께 날아갑니다. 서비스 클래스 안에서 무심코 되돌림을 호출했는데, 그 서비스를 부른 상위 프로그램이 여러 건을 묶어 처리하던 중이었다면 앞서 처리한 건까지 사라집니다. 되돌림은 트랜잭션을 시작한 쪽이 결정하고, 아래 계층은 예외를 던지는 데까지만 책임지는 편이 안전합니다.

직접 해보기

1. 예외 클래스에 메시지와 속성을 붙인다

" 메시지 클래스 ZINSP 에 011 등록
" &1 설비를 찾을 수 없습니다. 요청자: &2

CONSTANTS:
  BEGIN OF equip_not_found,
    msgid TYPE symsgid VALUE 'ZINSP',
    msgno TYPE symsgno VALUE '011',
    attr1 TYPE scx_attrname VALUE 'MV_EQUIP_ID',
    attr2 TYPE scx_attrname VALUE 'MV_REQUESTER',
    attr3 TYPE scx_attrname VALUE '',
    attr4 TYPE scx_attrname VALUE '',
  END OF equip_not_found.

2. 업무 규칙 위반은 정적 검사로 선언한다

METHODS create_request
  IMPORTING is_request        TYPE zinsp_request_s
  RETURNING VALUE(rv_req_id)  TYPE zinsp_req_id
  RAISING   zcx_insp_request.

3. 원인 사슬을 유지한 채 계층을 넘긴다

CATCH zcx_cost_api INTO DATA(lx_cost).
  RAISE EXCEPTION TYPE zcx_insp_request
    EXPORTING textid   = zcx_insp_request=>cost_calc_failed
              previous = lx_cost.

4. 호출부에서 사슬을 펼쳐 기록한다

CATCH zcx_insp_request INTO DATA(lx).
  DATA(lo_cursor) = CAST cx_root( lx ).
  WHILE lo_cursor IS BOUND.
    APPEND VALUE #( text = lo_cursor->get_text( ) ) TO lt_log.
    lo_cursor = lo_cursor->previous.
  ENDWHILE.

사슬을 끝까지 펼쳐 기록해 두면, 나중에 로그만 보고도 어느 계층에서 처음 어긋났는지 바로 짚을 수 있습니다.

자주 만나는 함정

  • CATCH cx_root로 전부 받는 코드 — 프로그램 결함까지 업무 오류로 둔갑해 조용히 넘어간다
  • 예외 메시지에 내부 테이블 이름이나 경로를 그대로 노출 — 외부 인터페이스 응답에 실리면 정보 노출이 된다
  • 메시지 속성 이름을 오타로 넣은 경우 — 값이 안 채워져 &1이 그대로 화면에 보인다
  • 반복문 안에서 예외를 잡고 계속 도는 코드 — 실패 건수만 늘고 원인은 마지막 것만 남는다
  • 단위 테스트에서 예외 경로를 안 만드는 습관 — 실패 흐름은 테스트에서 한 번은 반드시 통과시켜야 한다
  • 화면 메시지와 로그 메시지를 같은 문장으로 쓰는 구성 — 사용자에게는 조치 안내, 로그에는 원인 정보가 필요하다

핵심 한 줄

예외는 던지는 문법이 아니라 호출자가 무엇을 할 수 있게 할 것인가를 정하는 설계이며, 메시지·검사 종류·원인 사슬·트랜잭션 경계 네 가지가 그 설계의 뼈대다.

더 파볼 주제

  • 재개 가능한 예외를 써야 하는 상황과 쓰지 말아야 하는 상황
  • 인터페이스 응답 규약에서 오류 코드와 예외 클래스를 매핑하는 방법
  • 대량 처리에서 건별 실패를 모아 보고하는 구조
  • 단위 테스트에서 예외 경로를 검증하는 패턴

댓글 0

아직 댓글이 없습니다.