BTP 비용 90%는 여기서 새고 있다 — Cost Optimization #shorts #SAP #BTP

Moderator · 조회 2
#SAP #BTP #Cost #Optimization #CloudCredit

개요 및 학습 목표

SAP BTP(Business Technology Platform)를 운영하다 보면 "왜 이렇게 크레딧 소진이 빠르지?"라는 의문을 한 번쯤은 마주합니다. 개발용으로 잠깐 띄워둔 앱 하나, 테스트로 바인딩한 HANA Cloud 인스턴스 하나가 매월 수백 유로를 잠식하기도 합니다. 이 튜토리얼에서는 운영 환경에서 즉시 적용 가능한 3가지 핵심 비용 절감 전략을 다룹니다.

선수 지식

본 튜토리얼은 BTP 환경에 한 번이라도 앱을 배포해본 중급 개발자를 대상으로 합니다. Cloud Foundry CLI(cf CLI) 기본 명령어, manifest.yml 구조, Node.js 또는 Java 기반 CAP 프로젝트 구조에 대한 이해가 있으면 이상적입니다. 또한 BTP의 서브계정(subaccount), 스페이스(space), 글로벌 계정(global account) 개념을 알고 있어야 합니다.

환경 / 버전 / 준비물

본 가이드는 다음 환경을 기준으로 작성되었습니다.

cf CLI가 설치되어 있는지 다음 명령으로 확인합니다.

cf --version
cf api https://api.cf.eu10.hana.ondemand.com
cf login --sso

핵심 개념: 왜 BTP 비용이 예상보다 많이 나오는가?

BTP의 비용은 "내가 사용한 만큼"이 아니라 "내가 점유한 만큼" 청구되는 구조입니다. 호텔 객실에 비유하면 이해가 쉽습니다. 객실에 들어가지 않더라도 체크인한 순간부터 요금이 부과됩니다. CF 앱이 traffic이 0이어도 메모리를 점유한 시간만큼 크레딧이 차감됩니다.

BTP 크레딧 소비 방식

BTP의 크레딧 소비는 일반적으로 다음 4가지 축으로 결정됩니다.

핵심 인사이트: 90%의 비용 누수는 "잊혀진 리소스"에서 발생합니다. PoC가 끝났는데 stop만 하고 delete하지 않은 앱, 바인딩이 끊겼는데도 살아있는 서비스 인스턴스, 1GB면 충분한데 4GB로 배포된 앱 — 이 셋이 주범입니다.

비용 구조 도식

[Global Account]
   └── [Directory] (선택)
         └── [Subaccount] (Cloud Foundry 활성화)
               └── [Org] → [Space]
                     ├── [App] (메모리 × 인스턴스 × 시간)
                     ├── [Service Instance] (플랜 × 시간)
                     └── [Route] (대부분 무료지만 일부 도메인 과금)

크레딧은 글로벌 계정 단위로 차감되지만, 실제 소비는 스페이스의 앱과 서비스에서 발생합니다. 따라서 정리 작업은 항상 스페이스 단위로 진행하는 것이 효율적입니다.

실전 코드

1단계 — 전략 1: 미사용 CF 앱·서비스 인스턴스 정리

가장 먼저 할 일은 현재 무엇이 떠 있는지 파악하는 것입니다. 다음 명령어 시퀀스로 현황을 점검합니다.

# 현재 스페이스의 모든 앱 조회
cf apps

# 모든 서비스 인스턴스 조회 (last operation 컬럼 주목)
cf services

# 특정 앱의 상세 정보: 메모리, 인스턴스 수, 라우트 확인
cf app my-legacy-app

# 앱이 어떤 서비스에 바인딩되어 있는지 확인
cf env my-legacy-app

여기서 requested state가 stopped인 앱은 메모리 과금은 멈췄지만 디스크와 라우트는 여전히 점유합니다. 사용하지 않는다고 판단되면 다음과 같이 안전하게 삭제합니다.

# 1) 라우트 매핑 해제 (선택)
cf unmap-route my-legacy-app cfapps.eu10.hana.ondemand.com --hostname legacy-app

# 2) 서비스 바인딩 해제
cf unbind-service my-legacy-app my-hana-instance

# 3) 앱 삭제 (-r 옵션은 라우트도 함께 삭제)
cf delete my-legacy-app -r -f

# 4) 더 이상 어떤 앱에도 바인딩되지 않은 서비스 인스턴스 삭제
cf delete-service my-hana-instance -f

2단계 — 전략 2: manifest.yml 메모리·인스턴스 최적화 (실무 시나리오)

많은 팀이 manifest.yml을 작성할 때 "넉넉하게" 메모리를 잡는 습관이 있습니다. Node.js 앱이 실제로는 256MB로 충분한데 1GB로 배포되면 비용은 4배가 됩니다. 다음은 실무에서 자주 보는 비효율적 manifest와 최적화된 버전 비교입니다.

# 비효율적 예시 (변경 전)
applications:
  - name: order-service
    memory: 2G
    instances: 3
    disk_quota: 2G
    buildpacks:
      - nodejs_buildpack
    env:
      NODE_ENV: production
# 최적화된 예시 (변경 후)
applications:
  - name: order-service
    memory: 512M
    instances: 2
    disk_quota: 1G
    buildpacks:
      - nodejs_buildpack
    health-check-type: http
    health-check-http-endpoint: /health
    env:
      NODE_ENV: production
      NODE_OPTIONS: "--max-old-space-size=400"
      OPTIMIZE_MEMORY: "true"
    services:
      - hana-db
      - xsuaa-auth

최적화 포인트를 정리하면 다음과 같습니다.

실제 메모리 사용량은 다음 명령으로 모니터링합니다.

cf app order-service
# 출력 예: instance #0 memory 287.4M of 512M (56.1%)

# 통계 로그를 시계열로 보고 싶다면
cf logs order-service --recent | grep "CELL/SSHD\|memory"

3단계 — 전략 3: CAP 외부 API 캐싱으로 호출 수 줄이기 (프로덕션)

S/4HANA, Successfactors, 또는 외부 결제 API를 호출하는 CAP 서비스는 호출 수가 비용에 직결됩니다. 동일한 마스터 데이터를 매 요청마다 외부에 묻는 대신, 메모리 또는 Redis 캐시로 한 번 받아서 재사용합니다.

// srv/external-cache.js
const cds = require('@sap/cds');

// TTL 기반 간단 인메모리 캐시 (소규모 인스턴스용)
class TTLCache {
  constructor(ttlMs = 5 * 60 * 1000) {
    this.ttl = ttlMs;
    this.store = new Map();
  }
  get(key) {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }
  set(key, value) {
    this.store.set(key, { value, expiresAt: Date.now() + this.ttl });
  }
}

module.exports = cds.service.impl(async function () {
  const ext = await cds.connect.to('S4_BusinessPartner');
  const cache = new TTLCache(10 * 60 * 1000); // 10분

  this.on('READ', 'BusinessPartners', async (req) => {
    const cacheKey = `bp:${JSON.stringify(req.query.SELECT.where || 'all')}`;
    const cached = cache.get(cacheKey);
    if (cached) {
      req.info(200, 'served from cache');
      return cached;
    }
    const result = await ext.run(req.query);
    cache.set(cacheKey, result);
    return result;
  });
});

다중 인스턴스 환경(인스턴스 2개 이상)에서는 인메모리 캐시가 인스턴스마다 따로 동작하므로 Redis on SAP BTP를 사용하는 것이 일반적으로 권장됩니다.

// srv/redis-cache.js — 프로덕션용
const cds = require('@sap/cds');
const xsenv = require('@sap/xsenv');
const { createClient } = require('redis');

let redisClient;

async function getRedis() {
  if (redisClient?.isOpen) return redisClient;
  const { credentials } = xsenv.getServices({ redis: { tag: 'redis' } }).redis;
  redisClient = createClient({
    url: `rediss://:${credentials.password}@${credentials.hostname}:${credentials.port}`,
    socket: { tls: true }
  });
  redisClient.on('error', (err) => cds.log('redis').error(err));
  await redisClient.connect();
  return redisClient;
}

module.exports = cds.service.impl(async function () {
  const ext = await cds.connect.to('S4_BusinessPartner');
  const log = cds.log('cache');

  this.on('READ', 'BusinessPartners', async (req, next) => {
    const key = `bp:${req.user.id}:${JSON.stringify(req.query.SELECT.where || '*')}`;
    try {
      const r = await getRedis();
      const hit = await r.get(key);
      if (hit) {
        log.info('cache hit', key);
        return JSON.parse(hit);
      }
      const result = await ext.run(req.query);
      await r.setEx(key, 600, JSON.stringify(result));
      return result;
    } catch (e) {
      log.warn('cache bypass', e.message);
      return next();
    }
  });
});

캐시 적용 후에는 호출량을 모니터링해 효과를 검증합니다. 일반적으로 마스터 데이터 조회는 70~95%의 캐시 히트율을 기대할 수 있고, 이는 외부 API 호출 비용 및 백엔드 부하를 동시에 줄입니다.

BTP Cockpit에서 비용 모니터링하기

BTP Cockpit의 글로벌 계정 화면에서 다음 메뉴를 활용합니다.

CLI에서 사용량 데이터를 가져올 수도 있습니다. btp CLI로 다음을 시도하세요.

btp --format json get accounts/global-account --show-hierarchy
btp --format json list accounts/usage --from 2026-04-01 --to 2026-04-30

자동화: 주기적 리소스 정리 스크립트

매주 금요일 오후 6시에 stopped 상태로 7일 이상 방치된 앱을 식별하고, 슬랙으로 알림을 보내는 스크립트 예시입니다.

// scripts/idle-cleanup.js
const { execSync } = require('child_process');

function cf(cmd) {
  return execSync(`cf ${cmd}`, { encoding: 'utf8' });
}

function listIdleApps() {
  const json = cf('curl /v3/apps?per_page=200');
  const apps = JSON.parse(json).resources;
  const week = 7 * 24 * 60 * 60 * 1000;
  return apps.filter(a =>
    a.state === 'STOPPED' &&
    Date.now() - new Date(a.updated_at).getTime() > week
  );
}

(async () => {
  const idle = listIdleApps();
  console.log(`Idle candidates: ${idle.length}`);
  for (const a of idle) {
    console.log(`- ${a.name} (last updated ${a.updated_at})`);
  }
  // 슬랙/이메일 전송 로직은 환경에 맞게 추가
})();

이 스크립트를 BTP Job Scheduler 또는 GitHub Actions의 cron으로 등록하면 사람이 잊어도 자동으로 후보 리스트가 매주 도착합니다. 즉시 삭제하지 않고 알림만 보내는 이유는 운영 사고를 방지하기 위함입니다.

흔한 실수 / 트러블슈팅

FAQ 1. cf delete-service 했는데 "service instance is in use" 에러가 납니다

다른 앱에 바인딩이 남아있거나, 서비스 키(service-key)가 발급되어 있을 가능성이 높습니다. cf service my-instance로 bound apps와 service keys를 확인하고, cf unbind-service, cf delete-service-key를 먼저 실행하세요.

FAQ 2. 메모리를 줄였더니 앱이 OOM으로 자꾸 죽습니다

Node.js의 V8 힙은 기본적으로 컨테이너 메모리와 별개로 동작하므로 NODE_OPTIONS=--max-old-space-size=400 같이 명시해야 합니다. Java 앱은 SAP Java Buildpack의 JBP_CONFIG_OPEN_JDK_JRE로 메모리 가중치를 조정합니다. 또한 cf events로 실제 종료 코드(예: 137 = OOM Kill)를 확인하세요.

FAQ 3. 캐시를 적용했는데 사용자별로 다른 데이터가 보여야 합니다

캐시 키에 반드시 사용자 식별자나 테넌트 ID를 포함해야 합니다. 위 Redis 예제처럼 bp:${req.user.id}:... 형태로 키를 구성하고, 멀티테넌트 SaaS라면 req.tenant도 포함하세요. 권한 정책이 동적이라면 캐시 TTL을 짧게(1~3분) 가져가는 것이 안전합니다.

FAQ 4. Trial 계정인데도 크레딧이 빨리 소진됩니다

Trial은 일일 자동 stop이 적용되지만, HANA Cloud Trial은 일정 시간 미사용 시 자동 hibernation 됩니다. 그러나 일부 서비스(Object Store, Connectivity)는 stop 후에도 미세한 비용이 발생할 수 있으니 진짜로 안 쓰면 delete 하는 것을 권장합니다.

다음 단계 / 관련 주제

이 튜토리얼의 3가지 전략만 적용해도 일반적으로 30~60% 수준의 절감이 가능하며, 캐싱이 잘 맞는 워크로드에서는 90%까지도 보고됩니다. 더 깊이 파고들고 싶다면 다음 주제를 권장합니다.

참고 자료