CAP for Node

cds.tx() 없이 — CAP Node 롤백 실수 3가지 #shorts #SAP #CAPforNode

개요: 왜 트랜잭션 롤백을 정확히 알아야 하는가

CAP(SAP Cloud Application Programming Model) Node.js 런타임은 요청 단위로 트랜잭션을 자동 관리해 줍니다. 덕분에 대부분의 경우 개발자가 커밋과 롤백을 직접 호출할 일이 없지만, 바로 그 편리함 때문에 언제 롤백이 되고 언제 안 되는지를 모른 채 코드를 작성하다가 데이터 정합성이 깨지는 사고가 자주 발생합니다. 이 글은 창고 입출고 도메인을 가상의 실무 시나리오로 삼아, CAP Node.js의 트랜잭션 동작 원리와 롤백 처리법을 단계별로 정리합니다.

  • cds.Request 컨텍스트 안에서 트랜잭션이 자동으로 열리고 닫히는 과정을 설명할 수 있다
  • 에러 발생 시 자동 롤백이 되는 조건과 되지 않는 예외 케이스를 구분할 수 있다
  • cds.tx() / srv.tx()로 명시적 트랜잭션을 열고 커밋·롤백을 직접 제어할 수 있다
  • 배치 작업, 원격 서비스 호출처럼 요청 컨텍스트 밖의 상황을 안전하게 처리할 수 있다

미리 갖추면 좋은 배경지식

이 글은 중급 난이도를 기준으로 합니다. CAP으로 CDS 엔티티와 서비스를 정의해 본 경험, Node.js의 async/await와 예외 처리(try/catch)에 대한 이해, 그리고 데이터베이스 트랜잭션의 ACID 개념(특히 원자성)을 알고 있으면 내용을 따라오기 수월합니다. SELECT / INSERT / UPDATE 같은 CAP의 CQL(CDS Query Language) 전역 API를 써 본 적이 있다면 코드 예제를 바로 읽을 수 있습니다.

실습 환경과 버전 정보

예제는 다음 환경을 기준으로 작성했습니다. 트랜잭션 관리의 핵심 원리는 @sap/cds 7.x부터 동일하게 적용되므로, 버전이 조금 달라도 개념은 그대로 유효합니다.

  • Node.js 20 LTS 이상
  • @sap/cds 8.x (cds-dk 8.x로 프로젝트 생성)
  • 개발 DB: SQLite(인메모리), 운영 DB: SAP HANA Cloud — 두 환경 모두 동일한 트랜잭션 시맨틱으로 동작하도록 CAP이 추상화합니다
  • 배포 대상: SAP BTP Cloud Foundry 런타임 (선택)

핵심 개념: CAP이 트랜잭션을 관리하는 방식

CAP Node.js의 트랜잭션 모델을 택배 상자에 비유하면 이해가 쉽습니다. 클라이언트 요청 하나가 들어오면 CAP은 상자(트랜잭션)를 하나 열고, 그 요청을 처리하는 동안 발생하는 모든 DB 변경을 같은 상자에 담습니다. 요청 처리가 정상적으로 끝나면 상자를 봉인(커밋)하고, 처리 도중 예외가 밖으로 던져지면 상자째 폐기(롤백)합니다. 이때 "지금 어느 상자에 담아야 하는가"를 추적하는 운송장이 바로 cds.context입니다. CAP은 Node.js의 AsyncLocalStorage를 이용해 비동기 호출 체인 전체에 이 컨텍스트를 이어 붙이므로, 핸들러 안에서 await를 여러 번 거쳐도 같은 트랜잭션에 자동으로 참여합니다.

구체적으로는 이렇게 동작합니다. 첫째, 서비스 이벤트 핸들러(on / before / after)에 전달되는 reqcds.Request 인스턴스이며, 요청별 루트 트랜잭션에 연결되어 있습니다. 핸들러 안에서 SELECT, INSERT, UPDATE, DELETE를 실행하면 별도 지정이 없어도 현재 컨텍스트의 트랜잭션을 통해 실행됩니다. 둘째, 커밋과 롤백의 기준은 요청이 성공으로 끝났는가입니다. 핸들러에서 예외를 던지거나 req.reject()를 호출하거나 req.error()로 오류를 수집하면 요청은 실패로 판정되고, 루트 트랜잭션 전체가 롤백됩니다. 셋째, OData $batch 요청의 changeset은 ChangeSetContext로 묶여 하나의 원자적 단위로 처리되므로, changeset 내 한 건이 실패하면 그 묶음 전체가 롤백됩니다.

cds.tx는 일반적으로 세 가지 형태로 사용합니다.

형태커밋/롤백주 용도
srv.tx(req) 또는 cds.tx(req)요청 종료 시 자동기존 요청 컨텍스트에 참여(다른 서비스 호출 시)
cds.tx(async tx => { ... })함수 정상 반환 시 커밋, 예외 시 롤백 자동요청 밖(배치·잡)에서 새 루트 트랜잭션
const tx = cds.tx()tx.commit() / tx.rollback() 직접 호출커밋 시점을 세밀하게 제어해야 할 때

반대로 자동 롤백이 되지 않는 대표적인 상황은 네 가지입니다. (1) catch 블록에서 에러를 삼키고 정상 응답을 반환한 경우 — 요청이 성공으로 끝나므로 그때까지의 변경이 커밋됩니다. (2) setTimeout이나 분리된 Promise처럼 요청 컨텍스트 밖에서 실행된 DB 작업 — 애초에 같은 트랜잭션에 속하지 않습니다. (3) 원격 서비스(외부 OData API 등) 호출 — CAP은 분산 트랜잭션을 지원하지 않으므로 로컬 DB가 롤백돼도 원격 변경은 남습니다. (4) 수동으로 연 cds.tx() — 커밋과 롤백은 전적으로 개발자 책임입니다.

실전 코드로 보는 3단계 롤백 처리

1단계 — 기본: 자동 롤백에 맡기기

창고 출고 확정 액션을 예로 들겠습니다. 재고 차감, 이동 이력 기록, 안전재고 검증, 지시 상태 갱신이 하나의 업무 단위입니다. 먼저 도메인 모델입니다.

namespace depot.ops;
using { cuid, managed } from '@sap/cds/common';

entity StockLots : cuid, managed {
  material  : String(40);   // 자재 코드
  binCode   : String(10);   // 적치 위치
  onHandQty : Integer;      // 현재고
  safetyQty : Integer;      // 안전재고
}

entity OutboundOrders : cuid, managed {
  orderNo  : String(20);
  material : String(40);
  reqQty   : Integer;
  status   : String(12) default 'OPEN';  // OPEN | DONE
}

entity StockMovements : cuid, managed {
  moveType : String(4);    // IN | OUT | ADJ
  material : String(40);
  qty      : Integer;
  refOrder : String(20);
}

서비스 구현에서는 트랜잭션 관련 코드를 한 줄도 쓰지 않습니다. 예외를 던지기만 하면 그 앞에서 실행한 (1)(2)의 변경이 함께 취소됩니다.

const cds = require('@sap/cds');

module.exports = class DepotService extends cds.ApplicationService {
  async init() {
    const { StockLots, OutboundOrders, StockMovements } = this.entities;

    this.on('confirmOutbound', async (req) => {
      const { orderId } = req.data;
      const order = await SELECT.one.from(OutboundOrders).where({ ID: orderId });
      if (!order) return req.reject(404, `출고 지시 없음: ${orderId}`);

      const lot = await SELECT.one.from(StockLots).where({ material: order.material });

      // (1) 재고 차감 — 현재 요청의 트랜잭션에 자동 참여
      await UPDATE(StockLots, lot.ID).with({ onHandQty: lot.onHandQty - order.reqQty });

      // (2) 이동 이력 기록 — 같은 트랜잭션
      await INSERT.into(StockMovements).entries({
        moveType: 'OUT', material: order.material,
        qty: order.reqQty, refOrder: order.orderNo
      });

      // (3) 검증 실패 시 예외 → (1)(2)가 한꺼번에 롤백된다
      if (lot.onHandQty - order.reqQty < lot.safetyQty)
        throw new Error(`안전재고 미달: ${order.material}`);

      // (4) 상태 갱신 후 정상 종료 → 요청 끝에서 일괄 커밋
      await UPDATE(OutboundOrders, orderId).with({ status: 'DONE' });
      return `출고 완료: ${order.orderNo}`;
    });

    return super.init();
  }
};

2단계 — 실무: 에러 처리와 로깅에서 저지르기 쉬운 함정

실무 코드에는 거의 항상 try/catch와 로깅이 들어갑니다. 그런데 다음처럼 에러를 삼키면 롤백이 일어나지 않습니다. 요청 자체가 성공으로 끝나기 때문입니다.

// 잘못된 예 — 재고는 차감됐는데 이력 기록만 실패한 상태로 커밋된다
this.on('confirmOutbound', async (req) => {
  try {
    await deductStock(req);      // 성공
    await writeMovement(req);    // 여기서 예외 발생
  } catch (e) {
    LOG.error('출고 실패', e);
    return { status: 'FAILED' }; // 정상 응답 반환 → 커밋됨!
  }
});

올바른 패턴은 로깅 후 반드시 요청을 실패로 끝내는 것입니다. req.error()를 호출하거나 예외를 다시 던지면 됩니다.

const LOG = cds.log('depot');

this.on('confirmOutbound', async (req) => {
  try {
    await deductStock(req);
    await writeMovement(req);
  } catch (e) {
    LOG.error('출고 처리 실패, 롤백 예정', {
      orderId: req.data.orderId, cause: e.message
    });
    // 요청을 실패로 종료해야 롤백이 발동한다 (throw e 도 동일 효과)
    return req.error(500, '출고 처리 중 오류가 발생해 변경이 취소되었습니다.');
  }
});

다른 내부 서비스를 호출할 때는 srv.tx(req)로 현재 요청 컨텍스트에 참여시킵니다. 같은 프로세스에서 같은 DB를 쓰는 서비스라면 하나의 트랜잭션으로 묶여, 어느 쪽이 실패해도 함께 롤백됩니다.

const billing = await cds.connect.to('BillingService');

this.on('confirmOutbound', async (req) => {
  await deductStock(req);
  // req를 넘겨 같은 트랜잭션 경계에 참여 — 이후 실패 시 요금 기록도 롤백
  await billing.tx(req).send('recordHandlingFee', { orderId: req.data.orderId });
  await writeMovement(req);
});

3단계 — 프로덕션: 명시적 트랜잭션, 백그라운드 작업, 테스트

요청 컨텍스트가 없는 야간 배치(재고 실사)에서는 트랜잭션을 직접 열어야 합니다. 함수형 cds.tx(fn)을 쓰면 커밋·롤백이 자동 처리되어 실수 여지가 적으므로 일반적으로 이 형태를 권장합니다.

// 권장: 함수가 정상 반환하면 커밋, 예외가 빠져나가면 롤백
async function recountStock(counts) {
  await cds.tx(async () => {
    for (const c of counts) {
      await UPDATE('depot.ops.StockLots')
        .where({ material: c.material }).with({ onHandQty: c.countedQty });
      await INSERT.into('depot.ops.StockMovements').entries({
        moveType: 'ADJ', material: c.material, qty: c.diff, refOrder: 'RECOUNT'
      });
    }
  }); // 전 건 성공 시에만 확정 — 한 건이라도 실패하면 전체 취소
}

커밋 시점을 직접 제어해야 한다면 수동 형태를 쓰되, catch에서 rollback을 빠뜨리지 않아야 합니다. 빠뜨리면 커넥션이 풀로 반환되지 않아 고갈로 이어질 수 있습니다.

async function recountStockManual(counts) {
  const tx = cds.tx(); // 새 루트 트랜잭션
  try {
    for (const c of counts) {
      await tx.run(UPDATE('depot.ops.StockLots')
        .where({ material: c.material }).with({ onHandQty: c.countedQty }));
    }
    await tx.commit();
  } catch (e) {
    await tx.rollback(); // 반드시 호출 — 누락 시 커넥션 누수
    throw e;
  }
}

요청 처리 중 오래 걸리는 후속 작업을 분리할 때는 setTimeout 대신 cds.spawn을 사용합니다. 별도 트랜잭션 컨텍스트가 만들어져 요청 트랜잭션과 안전하게 분리됩니다. 또한 원격 시스템 통지처럼 롤백이 불가능한 작업은 커밋이 확정된 뒤에 실행되도록 succeeded 이벤트에 걸어 두는 것이 안전합니다.

this.on('confirmOutbound', async (req) => {
  // ... DB 변경 작업 ...
  req.on('succeeded', () => {
    // 커밋 확정 이후에만 실행 — 롤백됐는데 운송사에 통지되는 사고 방지
    cds.spawn({}, async () => notifyCarrier(req.data.orderId));
  });
});

롤백 동작은 반드시 테스트로 고정해 두는 것이 좋습니다. cds.test로 실패 케이스 후 데이터가 원복됐는지 검증합니다.

const cds = require('@sap/cds');
const { GET, POST, expect } = cds.test(__dirname + '/..');

it('안전재고 미달이면 재고와 이력이 모두 원복된다', async () => {
  await expect(POST('/odata/v4/depot/confirmOutbound',
    { orderId: LOW_STOCK_ORDER })).to.be.rejected;
  const { data } = await GET('/odata/v4/depot/StockMovements?$count=true');
  expect(data['@odata.count']).to.equal(0); // 이력이 남아 있으면 롤백 실패
});

흔한 실수와 트러블슈팅 FAQ

Q1. catch에서 로그만 남겼는데 절반만 반영된 데이터가 커밋되어 있습니다.
A. 가장 흔한 사고 유형입니다. CAP의 롤백 기준은 "요청이 실패로 끝났는가"이므로, 에러를 잡은 뒤 정상 응답을 반환하면 커밋됩니다. 로깅 후 반드시 throw 하거나 req.error() / req.reject()로 종료해야 합니다.

Q2. setTimeout 안에서 DB를 호출했더니 컨텍스트 오류가 나거나 별도로 커밋됩니다.
A. 타이머 콜백은 AsyncLocalStorage 체인이 끊긴 지점이라 요청 트랜잭션에 속하지 않습니다. 백그라운드 작업은 cds.spawn으로 새 컨텍스트를 만들어 실행하는 것이 일반적입니다.

Q3. 로컬 DB는 롤백됐는데 외부 API로 보낸 변경은 그대로 남았습니다.
A. CAP은 원격 서비스에 대한 분산 트랜잭션(2PC)을 지원하지 않습니다. 원격 호출은 커밋 확정 후(succeeded 이벤트)로 미루거나, 실패 시 반대 방향 호출로 되돌리는 보상 로직(Saga 패턴)을 설계해야 합니다.

Q4. 수동 cds.tx() 사용 후 서버가 점점 느려지다 멈춥니다.
A. commit/rollback 누락으로 커넥션이 풀에 반환되지 않았을 가능성이 큽니다. try/catch로 두 경로를 모두 처리했는지 확인하고, 가능하면 자동 관리되는 cds.tx(fn) 형태로 바꾸는 것을 권장합니다.

Q5. $batch로 여러 건을 보냈는데 일부만 반영됩니다.
A. 원자성이 적용되는 단위는 changeset입니다. 함께 성공/실패해야 하는 요청들은 같은 changeset으로 묶어야 ChangeSetContext가 하나의 트랜잭션으로 처리합니다.

댓글 0

아직 댓글이 없습니다.