CAP for Java

@Requires 안 걸면 API 뚫릴까? — CAP Java #shorts #SAP #CAPforJava

▶ YouTube에서 보기

개요: 이 글에서 다루는 것

SAP CAP(Cloud Application Programming Model) Java 애플리케이션에서 @requires 어노테이션은 인증(Authentication)과 역할 기반 접근 제어를 선언적으로 처리하는 핵심 도구입니다. 이 글에서는 물류 창고 운영 시스템이라는 실무 시나리오를 예로 들어, 서비스 전체 보호부터 액션 단위의 세밀한 역할 제어, 그리고 프로덕션 배포 시 XSUAA 연동까지 단계별로 살펴봅니다.

  • CDS 모델에서 @requires를 선언하는 세 가지 레벨(서비스/엔티티/액션) 이해
  • 로컬 개발 환경에서 모의 사용자(mock user)로 인증 테스트하기
  • UserInfo API로 핸들러 코드에서 프로그래밍 방식 검사 추가하기
  • XSUAA 역할 컬렉션과 CDS 역할 이름을 매핑하는 프로덕션 구성

미리 알아두면 좋은 것

이 글은 중급 난이도로, CDS 모델링 기본 문법(entity, service 정의)과 Java/Spring Boot 프로젝트 구조에 대한 기초 경험을 전제로 합니다. Maven 빌드와 application.yaml 설정 파일을 다뤄본 적이 있다면 충분하며, XSUAA를 처음 접하더라도 본문에서 개념부터 설명하므로 따라올 수 있습니다.

환경과 준비물

이 예제는 다음 환경을 기준으로 작성되었습니다. 버전에 따라 설정 키 이름이 다를 수 있으므로 확인이 필요합니다.

  • CAP Java SDK 3.x (com.sap.cds 그룹의 cds-services 아티팩트) — 2.x에서도 대부분 동일하게 동작
  • Java 17 이상, Spring Boot 3.x
  • @sap/cds-dk (Node.js 기반 CDS 컴파일러, cds CLI)
  • 프로덕션 단계: SAP BTP Cloud Foundry 환경과 XSUAA 서비스 인스턴스

Spring 보안 통합을 위해 pom.xml에 다음 스타터를 추가합니다.

<dependency>
  <groupId>com.sap.cds</groupId>
  <artifactId>cds-starter-cloudfoundry</artifactId>
</dependency>

이 스타터는 cds-feature-identity(또는 XSUAA 지원)와 Spring Security 자동 구성을 함께 가져오므로, 별도의 보안 필터 체인을 직접 작성하지 않아도 @requires 선언이 곧바로 적용됩니다.

핵심 개념: @requires는 건물 출입증 검사와 같다

CAP의 인가 모델을 건물 보안에 비유하면 이해가 쉽습니다. @requires: 'authenticated-user'는 "출입증이 있는 사람만 로비에 들어올 수 있다"는 규칙이고, @requires: 'OpsAdmin'은 "관리자 배지를 가진 사람만 서버실에 들어갈 수 있다"는 규칙입니다. 검사는 프레임워크가 요청이 핸들러에 도달하기 전에 수행하므로, 비즈니스 로직 코드는 인증 걱정 없이 깨끗하게 유지됩니다.

@requires에 지정할 수 있는 값은 크게 세 종류입니다.

의미주 사용처
authenticated-user로그인한 사용자라면 누구나 허용 (익명 차단)서비스 전체 기본 보호
system-user사람이 아닌 기술 사용자(client credentials 토큰)백그라운드 잡, 서비스 간 호출
커스텀 역할 문자열해당 역할(스코프)을 가진 사용자만 허용업무별 세분화된 권한

동작 흐름은 다음과 같습니다. ① 클라이언트가 JWT 토큰과 함께 요청 → ② 인증 계층(XSUAA 또는 로컬 mock)이 토큰을 검증하고 UserInfo 객체 생성 → ③ CAP 런타임이 CDS 모델의 @requires 선언과 사용자 역할을 대조 → ④ 불일치 시 인증 안 됨이면 401, 역할 부족이면 403을 반환하고 핸들러는 아예 호출되지 않습니다.

한 가지 중요한 구분이 있습니다. @requires는 "누가 들어올 수 있는가"만 결정하는 반면, @restrict는 "들어온 사람이 어떤 행위(READ/WRITE)를 어떤 데이터 범위(where 조건)에서 할 수 있는가"까지 제어합니다. 즉 @requires: 'Viewer'@restrict: [{ grant: '*', to: 'Viewer' }]의 축약형이라고 볼 수 있으며, 인스턴스 단위 필터링이 필요해지는 순간 @restrict로 확장하는 것이 일반적인 패턴입니다.

실전 코드: 창고 운영 서비스 3단계 구축

1단계 — 기본: 서비스 레벨 인증 걸기

가상의 물류 도메인을 정의합니다. 출고 지시(OutboundOrders)와 도크 예약(DockReservations)을 관리하는 WarehouseOpsService입니다.

namespace logi.wm;

entity OutboundOrders {
  key ID          : UUID;
  orderNo         : String(20);
  destination     : String(100);
  status          : String(10) default 'NEW';
  priority        : Integer;
}

entity DockReservations {
  key ID          : UUID;
  dockNo          : String(5);
  slotStart       : DateTime;
  slotEnd         : DateTime;
  order           : Association to OutboundOrders;
}
using { logi.wm as wm } from '../db/schema';

@requires: 'authenticated-user'
service WarehouseOpsService {
  entity OutboundOrders   as projection on wm.OutboundOrders;
  entity DockReservations as projection on wm.DockReservations;
}

서비스 정의 위에 붙인 @requires: 'authenticated-user' 한 줄로, 이 서비스의 모든 엔드포인트는 익명 접근이 차단됩니다. 로컬에서 확인하려면 application.yaml에 모의 사용자를 등록합니다.

cds:
  security:
    mock:
      users:
        - name: picker.kim
          password: pass1234
        - name: planner.lee
          password: pass1234
          roles: [ OpsPlanner ]

애플리케이션 실행 후 인증 없이 GET /odata/v4/WarehouseOpsService/OutboundOrders를 호출하면 401이 반환되고, picker.kim으로 Basic 인증을 붙이면 정상 조회됩니다.

2단계 — 실무: 역할 세분화와 거부 로깅

실무에서는 "조회는 전원, 도크 확정은 계획 담당자만" 같은 세분화가 필요합니다. 액션과 엔티티에 역할을 지정합니다.

@requires: 'authenticated-user'
service WarehouseOpsService {

  @restrict: [
    { grant: 'READ',  to: 'authenticated-user' },
    { grant: 'WRITE', to: 'OpsPlanner' }
  ]
  entity OutboundOrders as projection on wm.OutboundOrders;

  entity DockReservations as projection on wm.DockReservations;

  @requires: 'OpsPlanner'
  action confirmDockSlot(reservationId : UUID) returns String;

  @requires: 'OpsAdmin'
  action purgeExpiredReservations() returns Integer;
}

액션 핸들러에서는 UserInfo로 감사 로그를 남기고, 역할만으로 표현하기 어려운 추가 조건(예: 야간 시간대 차단)을 프로그래밍 방식으로 검사할 수 있습니다.

@Component
@ServiceName("WarehouseOpsService")
public class DockSlotHandler implements EventHandler {

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

  @On(event = "confirmDockSlot")
  public void onConfirm(ConfirmDockSlotContext ctx) {
    UserInfo user = ctx.getUserInfo();
    log.info("Dock confirm requested by={} tenant={}",
        user.getName(), user.getTenant());

    // 역할 외 추가 조건: 야간에는 OpsAdmin만 확정 가능
    int hour = LocalTime.now(ZoneId.of("Asia/Seoul")).getHour();
    if ((hour >= 22 || hour < 6) && !user.hasRole("OpsAdmin")) {
      throw new ServiceException(ErrorStatuses.FORBIDDEN,
          "야간 시간대 도크 확정은 관리자 권한이 필요합니다.");
    }

    // ... 예약 확정 로직
    ctx.setResult("CONFIRMED");
    ctx.setCompleted();
  }
}

포인트는 두 가지입니다. 첫째, @requires: 'OpsPlanner' 검사는 이 핸들러 진입 전에 이미 끝났으므로 핸들러 안에서는 "그 이상"의 조건만 다루면 됩니다. 둘째, 거부 사유를 ServiceExceptionErrorStatuses.FORBIDDEN으로 던지면 CAP이 표준 OData 오류 응답으로 변환해 줍니다.

3단계 — 프로덕션: XSUAA 매핑, 테스트, 보안 점검

BTP에 배포하면 mock 사용자는 비활성화되고 XSUAA가 발급한 JWT의 스코프가 역할이 됩니다. CDS의 역할 이름이 XSUAA 스코프와 연결되도록 xs-security.json을 작성합니다.

{
  "xsappname": "warehouse-ops",
  "tenant-mode": "dedicated",
  "scopes": [
    { "name": "$XSAPPNAME.OpsPlanner", "description": "출고 계획 담당" },
    { "name": "$XSAPPNAME.OpsAdmin",   "description": "창고 운영 관리자" }
  ],
  "role-templates": [
    { "name": "OpsPlanner", "scope-references": [ "$XSAPPNAME.OpsPlanner" ] },
    { "name": "OpsAdmin",   "scope-references": [ "$XSAPPNAME.OpsAdmin" ] }
  ]
}

CAP 런타임은 토큰의 $XSAPPNAME. 접두어를 자동으로 제거하고 비교하므로, CDS의 @requires: 'OpsPlanner'와 위 스코프 이름 뒷부분이 일치하면 됩니다. BTP 콕핏에서 role-template 기반 역할을 역할 컬렉션(Role Collection)에 담아 사용자에게 할당하는 것이 일반적인 운영 방식입니다. 배포 전 다음을 점검하는 것이 좋습니다.

  • 모든 서비스에 최소 @requires: 'authenticated-user'가 선언되었는가 (무선언 서비스는 실수로 열릴 수 있음)
  • 백그라운드 연동 엔드포인트에는 @requires: 'system-user'를 사용했는가
  • 운영 프로파일에서 mock 사용자 설정이 제외되는가 (application.yaml의 프로파일 분리)

마지막으로 인가 규칙 자체를 회귀 테스트로 고정합니다. Spring의 MockMvc와 CAP mock 인증을 조합하면 배포 없이 401/403 동작을 검증할 수 있습니다.

@SpringBootTest
@AutoConfigureMockMvc
class AuthPolicyTest {

  @Autowired MockMvc mvc;

  @Test
  void anonymousIsRejected() throws Exception {
    mvc.perform(get("/odata/v4/WarehouseOpsService/OutboundOrders"))
       .andExpect(status().isUnauthorized());
  }

  @Test
  void pickerCannotConfirmDock() throws Exception {
    mvc.perform(post("/odata/v4/WarehouseOpsService/confirmDockSlot")
        .with(httpBasic("picker.kim", "pass1234"))
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"reservationId\":\"11111111-1111-1111-1111-111111111111\"}"))
       .andExpect(status().isForbidden());
  }
}

흔한 실수와 트러블슈팅 FAQ

Q1. 로컬에서는 되는데 BTP 배포 후 계속 403이 발생합니다.

가장 흔한 원인은 XSUAA 역할 컬렉션 미할당입니다. xs-security.json에 스코프를 정의하는 것만으로는 부족하고, BTP 콕핏에서 역할 컬렉션을 만들어 실제 사용자에게 할당해야 합니다. 또한 스코프 수정 후에는 XSUAA 서비스 인스턴스를 업데이트(cf update-service)하고 새 토큰을 발급받아야 반영됩니다.

Q2. @requires를 붙였는데 익명으로도 접근이 됩니다.

보안 관련 의존성(cds-starter-cloudfoundry 또는 Spring Security 통합 모듈)이 클래스패스에 없으면 선언이 사실상 무시될 수 있습니다. 기동 로그에서 인증 관련 자동 구성이 활성화되었는지 확인하세요. 또 어노테이션 철자를 @Requires(대문자)로 쓰는 것은 CDS에서 소문자 @requires와 동일하게 인식되지만, 값의 역할 이름은 대소문자를 구분한다는 점에 주의해야 합니다.

Q3. 서비스 간 내부 호출까지 403으로 막힙니다.

기술 사용자 토큰(client credentials)으로 들어오는 호출은 authenticated-user만으로는 매칭이 애매할 수 있습니다. 내부 연동 전용 엔드포인트에 @requires: 'system-user'를 선언하거나, 애플리케이션 내부 로직이라면 RequestContext를 privileged 사용자로 전환해 실행하는 방식이 일반적으로 권장됩니다. 다만 privileged 실행은 인가를 완전히 우회하므로 범위를 최소화해야 합니다.

Q4. 401과 403이 섞여 나와 원인 파악이 어렵습니다.

401은 "토큰 자체가 없거나 유효하지 않음", 403은 "인증은 됐지만 역할 부족"입니다. 401이면 토큰 발급·바인딩(VCAP_SERVICES)을, 403이면 스코프 매핑을 먼저 확인하는 식으로 분리해서 접근하면 시간이 크게 절약됩니다.

이어서 살펴볼 주제

@requires로 진입 제어를 익혔다면, 다음 확장 방향을 추천합니다. 첫째, @restrictwhere 조건과 $user 속성을 활용한 인스턴스 단위 인가 — 예를 들어 "자기 창고 구역의 출고 지시만 조회" 같은 규칙입니다. 둘째, SAP Cloud Identity Services(IAS) 기반 인증으로의 전환과 XSUAA 대비 차이점. 셋째, 멀티테넌트 SaaS에서 UserInfo.getTenant()를 이용한 테넌트 격리 패턴입니다. 인가 모델이 복잡해질수록 이번 3단계에서 만든 인가 회귀 테스트가 안전망 역할을 하게 됩니다.

정리: 체크리스트로 되짚기

이 글에서 다룬 내용을 실무 적용 순서로 다시 정리하면 다음과 같습니다.

  1. 서비스 정의부에 @requires: 'authenticated-user'를 기본으로 걸어 익명 접근을 차단한다.
  2. 엔티티·액션 단위로 @restrict@requires를 조합해 READ/WRITE, 역할별 세분화를 적용한다.
  3. 역할 검사만으로 표현 안 되는 조건(시간대, 테넌트 등)은 핸들러에서 UserInfo로 프로그래밍 방식 검사를 추가한다.
  4. xs-security.json의 스코프·role-template을 BTP 역할 컬렉션에 연결하고 실제 사용자에게 할당한다.
  5. MockMvc 기반 인가 회귀 테스트로 401/403 동작을 코드로 고정해 배포마다 재검증한다.

이 다섯 단계를 순서대로 적용하면, 로컬 mock 인증에서 프로덕션 XSUAA 연동까지 끊김 없이 이어지는 인가 파이프라인을 구성할 수 있습니다.

댓글 0

아직 댓글이 없습니다.