CAP for Java

ServiceException 없이 500 — 매핑 3가지 #shorts #SAP #CAPJava

▶ YouTube에서 보기

개요: 왜 모든 오류가 500으로 나가는가

CAP Java로 서비스를 개발하다 보면, 핸들러 안에서 발생한 예외가 아무 처리 없이 그대로 올라가 클라이언트에게 500 Internal Server Error로 반환되는 상황을 자주 만납니다. 재고 부족처럼 명백한 "업무 오류"조차 500으로 나가면, 클라이언트는 서버 장애와 사용자 실수를 구분할 수 없고 Fiori 화면에는 의미 없는 기술 오류만 표시됩니다. 이 글은 CAP Java(SAP BTP, CAP Java SDK 2.x/3.x 기준)에서 ServiceExceptionErrorStatus/ErrorStatuses를 사용해 업무 예외를 의도한 400대 상태코드로 매핑하는 방법을 실전 예제로 다룹니다.

  • 처리되지 않은 예외가 500으로 변환되는 동작 원리 이해
  • ServiceException + ErrorStatuses로 400/404/409 매핑
  • 커스텀 ErrorStatus 정의와 i18n, 테스트까지 프로덕션 수준 적용
  • Fiori/UI5 클라이언트에서 상태코드와 오류 메시지를 활용하는 방법

미리 알아두면 좋은 배경

이 글은 중급 수준을 가정합니다. CAP Java 프로젝트 구조(srv 모듈, CDS 모델, @Before/@On/@After 이벤트 핸들러)와 Java의 checked/unchecked 예외 개념, OData V4 요청·응답의 기본 구조를 알고 있다면 무리 없이 따라올 수 있습니다. Spring Boot 기반 CAP Java 애플리케이션을 한 번이라도 실행해 본 경험이 있으면 충분합니다.

환경과 준비물

예제는 다음 환경을 기준으로 작성했습니다.

  • Java 17 이상 (Java 21 권장), Maven 3.9+
  • CAP Java SDK 2.x 이상 (3.x에서도 동일하게 동작, com.sap.cds:cds-services-api)
  • Spring Boot 3.x (CAP Java 아키타입 기본 구성)
  • SAP BTP Cloud Foundry 또는 로컬 mvn spring-boot:run 실행 환경
  • 테스트 호출용 REST 클라이언트(curl, Bruno 등) 또는 Fiori Elements 앱

여기서 사용하는 핵심 클래스는 com.sap.cds.services.ServiceException, com.sap.cds.services.ErrorStatus(인터페이스), com.sap.cds.services.ErrorStatuses(기본 제공 enum)이며 모두 cds-services-api에 포함되어 있어 별도 의존성 추가는 필요 없습니다.

핵심 개념: ServiceException과 ErrorStatus의 역할

CAP Java 런타임은 이벤트 핸들러에서 예외가 던져지면 이를 프로토콜 어댑터(OData V4 등) 레벨에서 HTTP 응답으로 변환합니다. 이때 규칙은 단순합니다. 런타임이 "의도된 오류"라고 인식할 수 있는 예외는 ServiceException이고, 그 외의 모든 예외(NullPointerException, 직접 만든 RuntimeException, 감싸지 않고 전파된 checked 예외 등)는 예상치 못한 내부 오류로 간주되어 500으로 응답됩니다. 비유하자면 ServiceException은 "고객 안내 방송용 마이크"이고, 나머지 예외는 "기계실 경보음"입니다. 경보음은 고객에게 그대로 들려줄 내용이 아니므로 런타임은 일반화된 500 메시지로 가려버립니다.

ServiceException은 unchecked 예외(RuntimeException 상속)라 시그니처 선언 없이 어디서든 던질 수 있고, 생성자에 ErrorStatus를 넘겨 HTTP 상태코드를 지정합니다. ErrorStatusgetCodeString()(오류 코드 문자열)과 getHttpStatus()(HTTP 상태코드)를 정의하는 인터페이스이고, ErrorStatuses는 자주 쓰는 값을 미리 구현해 둔 enum입니다.

ErrorStatuses 상수HTTP대표 업무 상황
BAD_REQUEST400입력값 검증 실패 (수량 0 이하 등)
FORBIDDEN403권한 없는 전표 접근
NOT_FOUND404존재하지 않는 주문 번호
CONFLICT409중복 생성, 잘못된 상태 전이, 재고 경합
PRECONDITION_FAILED412ETag 불일치(동시 수정)
SERVER_ERROR500진짜 내부 오류

보안 관점의 중요한 동작 하나: 일반적으로 CAP Java는 4xx ServiceException의 메시지는 클라이언트에 전달하지만, 5xx 오류의 상세 메시지는 정보 유출 방지를 위해 일반 문구로 대체합니다. 즉 "메시지를 사용자에게 보여주고 싶다면 반드시 4xx로 매핑해야 한다"는 뜻이기도 합니다. 또한 예외가 던져지면 현재 트랜잭션은 롤백되므로, 부분 커밋을 걱정할 필요 없이 검증 실패 시점에 바로 던지면 됩니다.

실전 코드: 3단계로 완성하는 상태코드 매핑

판매 주문 도메인을 예로 들겠습니다. CDS 모델은 다음과 같습니다.

service SalesOrderService {
  entity SalesOrders {
    key ID          : UUID;
    orderNo         : String(20);
    productId       : String(40);
    quantity        : Integer;
    status          : String(10); // DRAFT, SUBMITTED, APPROVED, REJECTED
  } actions {
    action approve();
  };
}

1단계 — 기본: 검증 실패를 400으로 내리기

가장 단순한 형태입니다. 주문 생성 전 @Before 핸들러에서 수량을 검증하고, 실패 시 ErrorStatuses.BAD_REQUEST로 던집니다.

@Component
@ServiceName(SalesOrderService_.CDS_NAME)
public class SalesOrderValidationHandler implements EventHandler {

  @Before(event = CqnService.EVENT_CREATE)
  public void validateQuantity(List orders) {
    for (SalesOrders order : orders) {
      if (order.getQuantity() == null || order.getQuantity() <= 0) {
        throw new ServiceException(ErrorStatuses.BAD_REQUEST,
            "주문 수량은 1 이상이어야 합니다. 입력값: {}", order.getQuantity())
          .messageTarget(SalesOrders_.class, so -> so.quantity());
      }
    }
  }
}

메시지의 {} 자리표시자에 인자가 채워지고, messageTarget(...)은 오류를 특정 필드(quantity)에 연결해 Fiori Elements가 해당 입력 필드 옆에 오류를 표시하도록 합니다. 응답 본문은 OData 표준 오류 포맷 {"error": {"code": "400", "message": "..."}}으로 내려갑니다.

2단계 — 실무 시나리오: 404/409 매핑과 로깅

액션 approve()에서 "주문 없음(404)", "잘못된 상태 전이(409)", "재고 부족(409)"을 구분해 처리하고, 내부 시스템 예외는 로깅 후 500으로 감쌉니다.

@Component
@ServiceName(SalesOrderService_.CDS_NAME)
public class SalesOrderApprovalHandler implements EventHandler {

  private static final Logger log =
      LoggerFactory.getLogger(SalesOrderApprovalHandler.class);

  private final PersistenceService db;
  private final InventoryClient inventory; // 외부 재고 시스템 클라이언트

  public SalesOrderApprovalHandler(PersistenceService db, InventoryClient inventory) {
    this.db = db;
    this.inventory = inventory;
  }

  @On(event = "approve", entity = SalesOrders_.CDS_NAME)
  public void onApprove(SalesOrdersApproveContext ctx) {
    SalesOrders order = db.run(ctx.getCqn()).first(SalesOrders.class)
      .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND,
          "해당 판매 주문을 찾을 수 없습니다."));

    // 잘못된 상태 전이: SUBMITTED 상태만 승인 가능
    if (!"SUBMITTED".equals(order.getStatus())) {
      throw new ServiceException(ErrorStatuses.CONFLICT,
          "'{}' 상태의 주문은 승인할 수 없습니다. 제출된 주문만 승인 가능합니다.",
          order.getStatus());
    }

    int available;
    try {
      available = inventory.getAvailableStock(order.getProductId());
    } catch (InventoryTimeoutException e) {
      // 기술 오류: 상세는 로그로, 클라이언트에는 500 (메시지는 자동으로 가려짐)
      log.error("재고 시스템 조회 실패. orderNo={}", order.getOrderNo(), e);
      throw new ServiceException(ErrorStatuses.SERVER_ERROR,
          "재고 시스템 연동 오류", e);
    }

    if (available < order.getQuantity()) {
      log.warn("재고 부족으로 승인 거절. orderNo={}, 요청={}, 가용={}",
          order.getOrderNo(), order.getQuantity(), available);
      throw new ServiceException(ErrorStatuses.CONFLICT,
          "재고가 부족합니다. 요청 수량 {}, 가용 재고 {}.",
          order.getQuantity(), available);
    }

    order.setStatus("APPROVED");
    db.run(Update.entity(SalesOrders_.class).data(order));
    ctx.setCompleted();
  }
}

포인트는 세 가지입니다. 첫째, checked 예외(InventoryTimeoutException)를 밖으로 새게 두지 않고 원인 예외를 cause로 보존한 채 ServiceException으로 감쌌습니다. 둘째, 업무 오류(재고 부족)는 409로 내려 사용자가 조치할 수 있게 하되 상세 수치를 메시지에 담았습니다. 셋째, 기술 오류는 클라이언트가 아니라 로그가 진실의 원천이 되도록 log.error에 전체 스택을 남겼습니다.

3단계 — 프로덕션: 커스텀 ErrorStatus, i18n, 테스트

프로덕션에서는 오류 코드를 체계화해 클라이언트가 코드 기반 분기를 할 수 있게 합니다. ErrorStatus 인터페이스를 구현한 enum을 정의합니다.

public enum OrderErrorStatuses implements ErrorStatus {

  STOCK_SHORTAGE("STOCK_SHORTAGE", 409),
  INVALID_STATUS_TRANSITION("INVALID_STATUS_TRANSITION", 409),
  DUPLICATE_ORDER_NO("DUPLICATE_ORDER_NO", 409),
  CREDIT_LIMIT_EXCEEDED("CREDIT_LIMIT_EXCEEDED", 400);

  private final String code;
  private final int httpStatus;

  OrderErrorStatuses(String code, int httpStatus) {
    this.code = code;
    this.httpStatus = httpStatus;
  }

  @Override public String getCodeString() { return code; }
  @Override public int getHttpStatus() { return httpStatus; }
}

메시지는 하드코딩 대신 리소스 번들 키를 사용합니다. srv/src/main/resources/messages.propertiesmessages_ko.properties에 키를 정의하면 CAP이 요청 로케일에 맞는 문구로 치환합니다.

# messages_ko.properties
order.stock.shortage = 재고가 부족합니다. 요청 수량 {}, 가용 재고 {}.
order.duplicate.no = 주문 번호 {}는 이미 존재합니다.
throw new ServiceException(OrderErrorStatuses.STOCK_SHORTAGE,
    "order.stock.shortage", order.getQuantity(), available);

이제 응답의 error.code"STOCK_SHORTAGE"처럼 의미 있는 값이 되어, 클라이언트는 HTTP 상태코드(대분류)와 오류 코드(세분류)를 조합해 처리할 수 있습니다. 검증은 단위 테스트로 고정해 둡니다.

@Test
void approveRejectedOrder_returnsConflict() {
  ServiceException ex = assertThrows(ServiceException.class,
      () -> approvalService.approve(rejectedOrderId));
  assertEquals(409, ex.getErrorStatus().getHttpStatus());
  assertEquals("INVALID_STATUS_TRANSITION", ex.getErrorStatus().getCodeString());
}

여러 필드를 한 번에 검증해야 한다면 Messages API(messages.error(...).target(...)messages.throwIfError())를 사용하면 수집된 오류가 하나의 400 응답에 details로 묶여 내려갑니다.

클라이언트(UI5) 측에서는 상태코드별로 UX를 다르게 가져갑니다. Fiori Elements는 4xx 응답의 error.message를 자동으로 메시지 팝업/메시지 버튼에 표시하고, messageTarget이 지정된 오류는 해당 필드에 하이라이트됩니다. 프리스타일 UI5라면 다음과 같이 분기합니다.

try {
  await oModel.bindContext("/SalesOrders(" + sId + ")/SalesOrderService.approve(...)")
    .execute();
} catch (oError) {
  const iStatus = oError.status;           // 404, 409 ...
  const sCode = oError.error?.code;        // "STOCK_SHORTAGE" 등
  if (iStatus === 409 && sCode === "STOCK_SHORTAGE") {
    MessageBox.warning(oError.error.message); // 재시도/수량 조정 유도
  } else if (iStatus === 404) {
    MessageBox.error("주문이 삭제되었거나 존재하지 않습니다.");
  } else {
    MessageBox.error("처리 중 오류가 발생했습니다. 관리자에게 문의하세요.");
  }
}

흔한 실수와 트러블슈팅

Q1. ServiceException을 던졌는데도 500이 나옵니다. 가장 흔한 원인은 다른 예외로 다시 감싸버린 경우입니다. 예를 들어 catch (Exception e) { throw new RuntimeException(e); } 같은 공통 catch 블록이 있으면 안쪽의 ServiceException이 일반 예외로 바뀌어 500이 됩니다. 감쌀 때는 ServiceException이면 그대로 재던지도록 분기하세요. 또 하나, new ServiceException("메시지")처럼 ErrorStatus 없이 생성하면 기본값이 SERVER_ERROR(500)라는 점도 자주 놓칩니다.

Q2. 4xx로 매핑했는데 클라이언트에 메시지가 안 보입니다. 메시지를 리소스 번들 키로 넘겼는데 messages.properties에 해당 키가 없으면 키 문자열이 그대로 노출되거나 기대와 다르게 표시됩니다. 로케일별 파일(messages_ko.properties)의 인코딩(UTF-8)과 키 오타를 확인하세요. 반대로 500인데 메시지가 안 보이는 것은 버그가 아니라 의도된 보안 동작입니다.

Q3. 400과 409 중 무엇을 써야 할지 헷갈립니다. 일반적으로 "요청 자체가 형식/값 수준에서 잘못됨"은 400, "요청은 유효하지만 현재 리소스 상태와 충돌함"(중복 주문번호, 이미 승인된 전표 재승인, 재고 경합)은 409가 자연스럽습니다. 팀 내에서 매핑 기준표를 한 번 정해두면 API 일관성이 크게 좋아집니다.

추가 주의사항으로, 예외 메시지에 내부 테이블명·스택 정보·시스템 URL 같은 내부 구현 정보를 넣지 마세요. 4xx 메시지는 사용자에게 그대로 노출됩니다. 상세 진단 정보는 로그로 보내고, 메시지는 사용자가 조치할 수 있는 내용만 담는 것이 권장됩니다.

여기서 더 나아가기

이 글의 패턴이 손에 익었다면 다음 주제로 확장해 보세요. 첫째, Messages API를 활용한 다건 검증 오류 수집과 OData error details 구조. 둘째, Fiori Elements의 Message Handling(전이 메시지 vs 상태 메시지)과 messageTarget의 바인딩 경로 규칙. 셋째, ETag 기반 낙관적 잠금과 412 PRECONDITION_FAILED 처리. 넷째, 멀티테넌트 환경에서 오류 로그와 SAP Cloud Logging 연계를 통한 관제 체계 구축. 상태코드 매핑은 API 계약의 일부이므로, OpenAPI 문서화와 함께 관리하면 소비 측 팀과의 협업이 한결 수월해집니다.

함께 보면 좋은 문서

댓글 0

아직 댓글이 없습니다.