CAP for Node

XSUAA vs Basic — CAP Node 인증 3가지 #shorts #SAP #CAPforNode

▶ YouTube에서 보기

📖 개요: CAP Node.js 인증 3가지 방식, 무엇을 언제 써야 할까

SAP BTP 위에서 CAP(Cloud Application Programming Model) Node.js 서비스를 개발하다 보면, 로컬에서 잘 돌던 서비스가 배포 후 401 오류를 뿜는 순간을 반드시 만나게 됩니다. 원인은 대부분 인증 전략(auth strategy)에 대한 이해 부족입니다. 이 글에서는 CAP Node.js가 제공하는 세 가지 인증 방식 — mocked(더미) 인증, basic 인증, XSUAA/JWT 기반 인증 — 을 실무 관점에서 비교하고, 프로파일별 전환 전략까지 단계별로 다룹니다.

  • 세 가지 인증 전략의 동작 원리와 차이를 설명할 수 있다
  • package.json / .cdsrc.json에서 프로파일별 인증 설정을 구성할 수 있다
  • xs-security.json으로 스코프·롤 템플릿을 정의하고 BTP에 배포할 수 있다
  • 개발/테스트/프로덕션 단계별로 어떤 방식을 택할지 판단 기준을 갖는다

📚 시작 전에 갖춰야 할 배경

CDS 모델링 기초(entity, service, projection)와 Node.js 서비스 핸들러 작성 경험이 있다는 전제로 진행합니다. OAuth 2.0의 토큰 개념(액세스 토큰, 스코프)을 대략적으로 알고 있으면 XSUAA 파트 이해가 훨씬 빨라집니다. BTP Cloud Foundry 환경에 cf CLI로 배포해 본 경험이 있으면 이상적입니다.

🔧 실습 환경과 버전

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

  • @sap/cds 8.x (CAP Node.js 런타임) — 7.x에서도 대부분 동일하게 동작하지만, 7.0부터 passport 의존성이 제거되고 자체 인증 미들웨어로 바뀐 점을 기억해 두세요
  • Node.js 20 LTS 이상
  • SAP BTP Cloud Foundry 환경 (트라이얼 또는 엔터프라이즈 계정, Kyma에서도 개념 동일)
  • @sap/xssec 4.x — XSUAA 토큰 검증 라이브러리. 3.x와 API가 다르므로 버전 확인 필수
  • cf CLI v8, 그리고 XSUAA 서비스 인스턴스를 생성할 수 있는 엔타이틀먼트

예제 시나리오는 사내 촬영 장비를 대여·반납하는 GearHubService입니다. 조회만 가능한 GearViewer 롤과 등록·수정까지 가능한 GearManager 롤 두 가지를 사용합니다.

💡 핵심 개념: CAP 인증은 "전략 교체형" 미들웨어다

CAP의 인증 설계를 건물 출입에 비유하면 이해가 쉽습니다. mocked 인증은 공사 중인 건물에 이름표 스티커만 붙이고 드나드는 것이고, basic 인증은 경비실 명부에 적힌 ID/비밀번호를 대조하는 방식이며, XSUAA/JWT는 중앙 인사시스템과 연동된 사원증 태깅 시스템입니다. 건물(서비스 코드)은 그대로 두고 출입 방식만 갈아 끼울 수 있다는 점이 핵심입니다.

기술적으로 CAP는 cds.requires.auth.kind 설정 하나로 인증 미들웨어를 교체합니다. 어떤 전략을 쓰든 요청이 서비스 핸들러에 도달할 때는 동일한 req.user 객체(cds.User 인스턴스)로 정규화됩니다. 즉:

  • req.user.id — 사용자 식별자
  • req.user.is('롤이름') — 롤(pseudo-role 포함) 보유 여부
  • req.user.attr — 인스턴스 기반 권한 제어에 쓰는 속성(부서, 지역 등)

덕분에 @requires, @restrict 같은 CDS 어노테이션과 커스텀 핸들러 코드는 인증 방식과 무관하게 재사용됩니다. 개발 단계에서 mocked로 빠르게 검증하고, 프로덕션 프로파일에서만 XSUAA로 전환하는 것이 일반적인 패턴입니다. 참고로 authenticated-user, system-user, any 같은 pseudo-role은 CAP가 미리 정의해 둔 특수 롤로, 별도 스코프 정의 없이 사용할 수 있습니다.

구분mockedbasicxsuaa (jwt)
용도로컬 개발·단위 테스트데모·간단한 내부 검증프로덕션
자격 증명설정 파일의 가짜 사용자ID/비밀번호(설정 기반)OAuth2 액세스 토큰
롤/속성설정에서 자유 지정설정에서 지정토큰의 스코프·속성
멀티테넌시불가불가지원
프로덕션 사용차단됨(기동 거부)비권장권장

💻 단계별 실전 예제

1단계 — mocked 인증으로 권한 모델 빠르게 검증하기

먼저 도메인 모델과 서비스를 정의합니다. 대여 장비와 대여 기록 두 엔티티입니다.

// db/schema.cds
namespace corp.gearhub;

entity GearItem {
  key ID       : UUID;
  label        : String(80);
  category     : String(30);   // camera, lens, tripod ...
  isAvailable  : Boolean default true;
}

entity LoanTicket {
  key ID       : UUID;
  gear         : Association to GearItem;
  borrowerId   : String(60);
  dueDate      : Date;
  returned     : Boolean default false;
}
// srv/gear-hub-service.cds
using corp.gearhub as db from '../db/schema';

service GearHubService @(requires: 'authenticated-user') {
  @(restrict: [
    { grant: 'READ', to: 'GearViewer' },
    { grant: '*',    to: 'GearManager' }
  ])
  entity GearItems as projection on db.GearItem;

  @(restrict: [
    { grant: 'READ',   to: 'GearViewer', where: 'borrowerId = $user' },
    { grant: '*',      to: 'GearManager' }
  ])
  entity LoanTickets as projection on db.LoanTicket;

  action checkoutGear(gearId: UUID, dueDate: Date) returns LoanTickets;
}

이제 package.json의 cds 섹션에 mocked 사용자를 선언합니다. 비밀번호 없는 사용자는 이름만으로 로그인되므로 개발 속도가 빠릅니다.

{
  "cds": {
    "requires": {
      "auth": {
        "kind": "mocked",
        "users": {
          "mina":  { "password": "dev-only", "roles": ["GearViewer"] },
          "jaeho": { "roles": ["GearViewer", "GearManager"],
                     "attr": { "dept": "MEDIA" } },
          "*": false
        }
      }
    }
  }
}

"*": false는 선언하지 않은 임의 사용자의 통과를 막는 설정입니다. cds watch 후 브라우저에서 mina/jaeho로 로그인해 보면, mina는 READ만 되고 jaeho는 생성·수정까지 되는 것을 즉시 확인할 수 있습니다. where: 'borrowerId = $user' 조건 덕분에 mina는 자기 대여 기록만 조회됩니다 — 인스턴스 기반 권한까지 로컬에서 검증되는 셈입니다.

2단계 — basic 인증 + 에러 처리·로깅이 있는 핸들러

사내 데모나 파트너 검수처럼 "진짜 비밀번호 대조는 필요하지만 XSUAA까지는 과한" 상황에서는 basic 전략을 씁니다. 설정은 kind만 바꾸면 됩니다. 프로파일 분리를 위해 .cdsrc.json을 활용해 보겠습니다.

{
  "requires": {
    "auth": {
      "[development]": { "kind": "mocked" },
      "[demo]": {
        "kind": "basic",
        "users": {
          "reviewer": { "password": "S3t-Via-Env!", "roles": ["GearViewer"] }
        }
      }
    }
  }
}

cds watch --profile demo로 기동하면 HTTP Basic 헤더의 ID/비밀번호를 대조합니다. 이제 서비스 핸들러에 인증 실패·권한 부족 상황에 대한 로깅과 방어 코드를 넣습니다.

// srv/gear-hub-service.js
const cds = require('@sap/cds')
const LOG = cds.log('gearhub')

module.exports = class GearHubService extends cds.ApplicationService {
  async init () {
    const { GearItems, LoanTickets } = this.entities

    // 모든 요청에 대해 사용자 컨텍스트 감사 로그
    this.before('*', (req) => {
      LOG.info(`user=${req.user.id} event=${req.event} entity=${req.target?.name}`)
    })

    this.on('checkoutGear', async (req) => {
      const { gearId, dueDate } = req.data
      if (!req.user.is('GearViewer'))
        return req.reject(403, '대여 권한이 없습니다. 관리자에게 롤을 요청하세요.')

      const gear = await SELECT.one.from(GearItems).where({ ID: gearId })
      if (!gear) return req.reject(404, `장비 ${gearId}를 찾을 수 없습니다.`)
      if (!gear.isAvailable) return req.reject(409, '이미 대여 중인 장비입니다.')

      try {
        const ticket = await INSERT.into(LoanTickets).entries({
          gear_ID: gearId, borrowerId: req.user.id, dueDate, returned: false })
        await UPDATE(GearItems, gearId).with({ isAvailable: false })
        return ticket
      } catch (err) {
        LOG.error('checkout failed', { user: req.user.id, gearId, err: err.message })
        return req.reject(500, '대여 처리 중 오류가 발생했습니다.')
      }
    })
    return super.init()
  }
}

핵심은 req.user만 바라보고 코드를 쓰는 것입니다. 이 핸들러는 3단계에서 XSUAA로 전환해도 한 줄도 고칠 필요가 없습니다.

3단계 — 프로덕션: XSUAA/JWT 인증

프로덕션에서는 BTP의 XSUAA(Authorization & Trust Management) 서비스가 발급한 OAuth2 JWT 토큰을 검증합니다. 먼저 스코프와 롤 템플릿을 xs-security.json에 정의합니다.

{
  "xsappname": "gearhub-srv",
  "tenant-mode": "dedicated",
  "scopes": [
    { "name": "$XSAPPNAME.GearViewer",  "description": "장비 조회 및 본인 대여" },
    { "name": "$XSAPPNAME.GearManager", "description": "장비 등록/수정/회수" }
  ],
  "attributes": [
    { "name": "dept", "valueType": "string", "description": "소속 부서" }
  ],
  "role-templates": [
    { "name": "GearViewer",  "scope-references": ["$XSAPPNAME.GearViewer"],
      "attribute-references": ["dept"] },
    { "name": "GearManager",
      "scope-references": ["$XSAPPNAME.GearViewer", "$XSAPPNAME.GearManager"] }
  ]
}

CDS 어노테이션의 롤 이름과 스코프 이름($XSAPPNAME. 뒤부분)이 일치해야 CAP가 자동 매핑합니다. 이어서 프로덕션 프로파일에만 xsuaa를 지정합니다.

{
  "cds": {
    "requires": {
      "auth": {
        "[development]": { "kind": "mocked" },
        "[production]":  { "kind": "xsuaa" }
      }
    }
  }
}

서비스 인스턴스 생성과 바인딩은 다음 순서입니다.

# mta.yaml 발췌
modules:
  - name: gearhub-srv
    type: nodejs
    path: gen/srv
    requires: [ { name: gearhub-auth } ]
resources:
  - name: gearhub-auth
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      path: ./xs-security.json

배포 후 BTP 콕핏에서 롤 템플릿으로 Role Collection을 만들어 사용자에게 할당해야 실제로 req.user.is('GearManager')가 true가 됩니다. 이 단계를 빠뜨리면 인증(401)은 통과해도 인가(403)에서 막힙니다. 배포 전 로컬에서 실제 XSUAA 토큰으로 검증하고 싶다면 cds bind -2 gearhub-authcds watch --profile hybrid로 하이브리드 테스트가 가능합니다. 마지막으로, 권한 회귀를 막는 자동화 테스트를 mocked 전략 위에서 돌립니다.

// test/authz.test.js
const cds = require('@sap/cds')
const { GET, POST, axios } = cds.test(__dirname + '/..')

it('GearViewer는 장비 생성이 거부되어야 한다', async () => {
  axios.defaults.auth = { username: 'mina', password: 'dev-only' }
  await expect(POST('/odata/v4/gear-hub/GearItems', { label: 'X' }))
    .rejects.toMatchObject({ status: 403 })
})

it('GearManager는 장비 생성이 가능해야 한다', async () => {
  axios.defaults.auth = { username: 'jaeho', password: '' }
  const { status } = await POST('/odata/v4/gear-hub/GearItems',
    { label: 'Mirrorless A7', category: 'camera' })
  expect(status).toBe(201)
})

실무 판단 기준을 정리하면 — 로컬 개발·CI 테스트는 mocked, 외부 IdP 없이 잠깐 보여줘야 하는 데모는 basic, BTP에 배포되는 모든 환경(스테이징 포함)은 xsuaa가 일반적입니다. 스테이징에서만 basic을 쓰면 롤 매핑 문제를 프로덕션에서 처음 발견하게 되므로 피하는 것이 좋습니다.

⚠️ 자주 겪는 문제와 해결법

Q1. 로컬에선 되는데 배포하면 무조건 401이 떠요.
대부분 (1) XSUAA 인스턴스가 srv 모듈에 바인딩되지 않았거나(VCAP_SERVICES 확인), (2) approuter 없이 브라우저로 직접 호출해 토큰 자체가 없는 경우입니다. JWT 전략은 Authorization: Bearer 헤더의 토큰을 검증할 뿐, 로그인 화면을 띄워주지 않습니다. approuter나 클라이언트가 토큰을 받아 전달해야 합니다.

Q2. 401은 아닌데 403이 계속 나옵니다.
인증은 성공했지만 인가 실패입니다. BTP 콕핏에서 Role Collection이 사용자에게 할당됐는지, xs-security.json의 스코프 이름과 CDS의 @restrict 롤 이름이 정확히 일치하는지 확인하세요. 롤 할당 후에는 기존 토큰이 만료될 때까지 반영되지 않으므로 재로그인이 필요합니다.

Q3. mocked 설정을 실수로 프로덕션에 배포하면 어떻게 되나요?
CAP는 일반적으로 production 프로파일에서 mocked/dummy 전략을 감지하면 기동을 거부합니다. 다만 NODE_ENV나 프로파일을 잘못 지정해 development로 뜨는 경우가 있으니, 배포 파이프라인에서 NODE_ENV=production을 명시적으로 확인하는 것이 안전합니다.

Q4. @sap/xssec 관련 기동 오류가 납니다.
@sap/cds 8.x는 @sap/xssec 4.x를 전제로 합니다. 3.x가 잠겨 있는 오래된 lockfile을 쓰고 있다면 재설치하세요. Kyma 환경이라면 서비스 바인딩 시크릿이 파드에 마운트됐는지도 함께 확인해야 합니다.

🚀 여기서 더 확장하기

세 가지 전략을 익혔다면 다음 주제로 넓혀 보세요. SAP Cloud Identity Services(IAS) 기반 인증은 XSUAA의 후속 방향으로 권장되는 추세이며 kind: "ias"로 전환할 수 있습니다. Authorization Management Service(AMS)로 정책 기반 인가를 얹는 것, approuter와 destination을 통한 토큰 전파(token exchange), 그리고 멀티테넌트 SaaS에서의 테넌트별 구독 처리도 자연스러운 다음 학습 지점입니다.

댓글 0

아직 댓글이 없습니다.