CAP for Node

CQL 최적화 vs N+1 — CAP 쿼리 3배 차이 #shorts #SAP #CAP

▶ YouTube에서 보기

개요: 왜 CAP 쿼리 성능을 다시 봐야 하는가

SAP CAP(Cloud Application Programming Model) Node.js 런타임은 CQL(CDS Query Language)을 통해 데이터베이스 접근을 추상화합니다. 편리한 만큼, 추상화 뒤에서 어떤 SQL이 실행되는지 의식하지 않으면 반복 쿼리(N+1)와 과도한 조인으로 응답 시간이 수 초 단위로 늘어나는 문제가 자주 발생합니다. 이 글은 CAP Node.js 서비스에서 성능을 갉아먹는 대표적인 안티패턴을 진단하고, CQL 차원에서 해결하는 접근법을 실전 예제로 정리합니다.

  • 루프 안 반복 쿼리(N+1)를 단일 쿼리로 병합하는 방법 이해
  • expand·조인·컬럼 프로젝션을 통한 데이터 전송량 최소화
  • DEBUG 로그로 실제 SQL을 확인하고 병목을 계측하는 습관
  • 대량 INSERT/UPSERT와 페이징 등 프로덕션 수준 최적화 적용

미리 알고 있으면 좋은 내용

CDS 모델링 기초(entity, association, projection)와 CAP Node.js 서비스 핸들러(srv.on, srv.before, srv.after) 작성 경험이 있으면 수월합니다. JavaScript의 async/await와 Promise 병렬 처리(Promise.all) 개념, 그리고 기본적인 SQL 조인 동작을 이해하고 있다고 가정합니다. OData v4 쿼리 옵션($expand, $select, $top)을 다뤄봤다면 더 좋습니다.

환경 / 버전 / 준비물

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

  • Node.js 20 LTS 이상, @sap/cds 8.x (CAP Node.js 런타임)
  • 로컬 개발: @cap-js/sqlite (인메모리 SQLite)
  • 프로덕션: SAP HANA Cloud (@cap-js/hana 드라이버), SAP BTP Cloud Foundry 환경
  • 도구: cds watch, DEBUG=sql 환경 변수, cds.test 기반 테스트

일반적으로 로컬 SQLite와 HANA Cloud는 옵티마이저 특성이 다르므로, 성능 검증은 반드시 HANA 인스턴스에 배포한 상태에서 수행하는 것이 권장됩니다. 프로젝트는 cds init order-mgmt로 생성한 표준 구조를 가정합니다.

핵심 개념: CQL 추상화 뒤에서 벌어지는 일

CQL은 "무엇을 원하는가"만 선언하면 런타임이 SQL로 번역해 주는 선언형 언어입니다. 비유하자면 CQL은 식당의 주문서이고, 데이터베이스는 주방입니다. 주문서를 한 장으로 정리해 내면 주방은 한 번에 조리하지만, 손님이 반찬 하나마다 주문서를 새로 넣으면(N+1 쿼리) 주방은 매번 왕복하며 시간을 낭비합니다.

성능 관점에서 기억할 CQL 동작 원리는 세 가지입니다.

  • 쿼리 1건 = DB 왕복 1회: await SELECT... 한 줄이 네트워크 왕복(round trip) 하나입니다. HANA Cloud처럼 원격 DB라면 왕복당 수 ms~수십 ms가 누적됩니다. 100건 루프면 최소 100회 왕복입니다.
  • expand는 조인 또는 서브쿼리로 번역: CAP 8.x의 신규 데이터베이스 서비스는 깊은 expand를 JSON 집계 기반 단일 쿼리로 처리하는 경향이 있지만, 다단계 expand에 컬럼 제한이 없으면 결과 집합이 기하급수적으로 커집니다.
  • 컬럼을 지정하지 않으면 전체 컬럼 조회: SELECT.from(Entity)는 사실상 SELECT *입니다. LargeString/LargeBinary 컬럼이 섞여 있으면 전송량이 급증합니다.

따라서 최적화의 핵심 전략은 왕복 횟수 줄이기(쿼리 병합), 데이터량 줄이기(프로젝션·페이징), 조인 범위 줄이기(필요한 expand만) 세 축으로 정리됩니다. 아래 실전 코드에서 이 순서대로 적용합니다.

실전 코드 1단계: N+1 안티패턴을 단일 쿼리로 병합하기

주문(SalesOrder)과 품목(OrderItem)을 다루는 주문 관리 서비스를 예로 듭니다. 먼저 흔히 보이는 안티패턴입니다.

// [안티패턴] 주문마다 품목을 개별 조회 → 주문 100건이면 101회 DB 왕복
const orders = await SELECT.from(SalesOrders).where({ status: 'OPEN' });
for (const order of orders) {
  order.items = await SELECT.from(OrderItems)
    .where({ parent_ID: order.ID });   // 루프 안 await = N+1
}

해결책은 두 가지입니다. 연관 데이터를 한 번에 가져오는 where in 병합, 또는 CQL의 중첩 프로젝션(expand)입니다.

// [개선 A] IN 절로 1회 왕복 병합
const orders = await SELECT.from(SalesOrders).where({ status: 'OPEN' });
const ids = orders.map(o => o.ID);
const items = await SELECT.from(OrderItems).where({ parent_ID: { in: ids } });
const grouped = Map.groupBy(items, i => i.parent_ID);
orders.forEach(o => o.items = grouped.get(o.ID) ?? []);

// [개선 B] CQL 중첩 expand — 런타임이 단일 실행 계획으로 처리
const result = await SELECT.from(SalesOrders, o => {
  o.ID, o.orderNo, o.status,
  o.items(i => { i.ID, i.product_ID, i.quantity })
}).where({ status: 'OPEN' });

개선 B처럼 화살표 함수 프로젝션으로 필요한 컬럼만 명시하면 왕복 횟수와 전송량을 동시에 줄일 수 있습니다.

실전 코드 2단계: 실무 시나리오 — 에러 처리와 SQL 로깅 계측

실무에서는 "느리다"는 제보만으로는 원인을 못 찾습니다. cds.logDEBUG=sql로 실제 실행되는 SQL을 확인하는 절차를 핸들러에 녹입니다.

const cds = require('@sap/cds');
const LOG = cds.log('order-service');

module.exports = class OrderService extends cds.ApplicationService {
  init() {
    const { SalesOrders } = this.entities;

    this.on('getOpenOrderSummary', async (req) => {
      const t0 = performance.now();
      try {
        // 집계는 앱 메모리가 아니라 DB에 위임 (조인·GROUP BY 푸시다운)
        const summary = await SELECT.from(SalesOrders)
          .columns('customer_ID', 'count(*) as orderCount',
                   'sum(grossAmount) as total')
          .where({ status: 'OPEN' })
          .groupBy('customer_ID')
          .orderBy('total desc')
          .limit(50);                       // 상위 50건만
        LOG.info(`summary rows=${summary.length}`,
                 `elapsed=${(performance.now() - t0).toFixed(1)}ms`);
        return summary;
      } catch (e) {
        LOG.error('summary query failed', e);
        return req.error(500, '주문 집계 조회에 실패했습니다.');
      }
    });
    return super.init();
  }
};

실행 시 DEBUG=sql cds watch로 기동하면 CQL이 번역된 실제 SQL과 바인딩 파라미터가 콘솔에 출력됩니다. 루프 안에서 같은 SQL이 파라미터만 바뀌며 반복 출력된다면 N+1이 숨어 있다는 신호입니다. 또 서로 의존성이 없는 조회는 순차 await 대신 병렬화합니다.

// 독립적인 두 조회는 병렬 실행 — 총 소요 시간이 max(a, b)로 단축
const [customers, products] = await Promise.all([
  SELECT.from(Customers).columns('ID', 'name'),
  SELECT.from(Products).columns('ID', 'title', 'price')
]);

실전 코드 3단계: 프로덕션 — 대량 처리, 페이징, 테스트, 보안

프로덕션에서는 대량 데이터 유입과 무제한 조회를 함께 통제해야 합니다.

// [대량 INSERT] 루프 대신 entries 배열로 단일 문 실행
const rows = payload.map(p => ({ ID: cds.utils.uuid(), ...p }));
await INSERT.into(OrderItems).entries(rows);   // 1회 왕복으로 벌크 삽입

// [UPSERT] 존재 여부를 먼저 SELECT하는 2왕복 패턴 대신 단일 문
await UPSERT.into(SalesOrders).entries({ ID, status: 'CONFIRMED' });

목록 조회에는 서버 측 페이징 상한을 걸어 클라이언트가 전체 테이블을 끌어가는 사고를 막습니다. CDS 모델에 애노테이션으로 선언합니다.

service OrderService {
  @cds.query.limit: { default: 100, max: 1000 }  // 기본 100건, 최대 1000건
  @restrict: [{ grant: 'READ', to: 'OrderViewer' }]  // 권한 없는 대량 조회 차단
  entity SalesOrders as projection on db.SalesOrders
    excluding { internalMemo };   // 무거운/민감한 컬럼은 서비스 노출 제외
}

성능 회귀를 막으려면 테스트에 쿼리 횟수 검증을 포함하는 방법이 유용합니다.

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

it('주문 목록은 기본 100건으로 제한된다', async () => {
  const { data } = await GET('/odata/v4/order/SalesOrders');
  expect(data.value.length).to.be.at.most(100);
});

마지막으로 자주 필터링하는 컬럼(예: status, customer_ID)에는 @sql.append 또는 네이티브 HANA 아티팩트로 인덱스를 검토합니다. 일반적으로 인덱스 추가 여부는 HANA의 SQL 분석(Explain Plan)으로 실측 후 결정하는 것이 권장됩니다.

흔한 실수와 트러블슈팅 FAQ

  • Q1. 로컬(SQLite)에서는 빠른데 BTP의 HANA Cloud에서만 느립니다. — 로컬 인메모리 DB는 왕복 지연이 거의 0이라 N+1이 드러나지 않습니다. 원격 HANA는 왕복당 지연이 누적되므로 쿼리 횟수 자체를 줄여야 합니다. DEBUG=sql로 요청당 쿼리 수를 먼저 세어 보세요.
  • Q2. $expand를 여러 단계 걸었더니 응답이 수 초 걸립니다. — expand 깊이가 늘수록 조인·중첩 집계 범위가 커집니다. 각 expand 레벨에서 $select(CQL에서는 컬럼 프로젝션)로 컬럼을 제한하고, 화면에 즉시 필요 없는 연관 데이터는 별도 지연 호출로 분리하는 편이 낫습니다.
  • Q3. after 핸들러에서 각 행마다 추가 조회를 하는데 대안이 있나요?srv.after('READ')에서 행 단위 await는 전형적 N+1입니다. 필요한 값을 원 쿼리의 expand나 계산 요소(calculated element)로 미리 포함시키거나, 결과 전체의 키를 모아 where in 1회 조회로 대체하세요.
  • Q4. 벌크 INSERT 중 일부 행 오류 시 전체가 실패합니다. — CAP 핸들러는 기본적으로 요청 단위 트랜잭션으로 묶입니다. 부분 성공을 허용해야 한다면 배치를 적정 크기(예: 500~1000행)로 나누고 배치별 오류를 로깅·재시도하는 설계가 일반적입니다.
  • Q5. draft 활성화 엔티티 목록 조회가 유독 느립니다. — draft는 활성 테이블과 draft 테이블의 UNION 성격 조회가 발생합니다. 목록 화면에서 불필요한 컬럼·expand를 줄이고 페이징 상한을 낮추는 것이 우선 대응입니다.

더 깊이 살펴볼 주제

이 글의 기법이 익숙해졌다면 다음 주제로 확장해 보세요. 첫째, CDS 계산 요소와 뷰 푸시다운으로 애플리케이션 로직을 DB 계층으로 옮기는 모델링 최적화. 둘째, HANA Cloud의 Explain Plan과 SQL Trace를 활용한 실행 계획 분석. 셋째, @sap/cds의 원격 서비스(외부 API) 호출 시 데이터 연합과 캐싱 전략. 넷째, OpenTelemetry 기반 CAP 계측으로 요청-쿼리 상관 추적을 구축하면 운영 단계 병목 진단이 한층 수월해집니다.

참고 링크 모음

댓글 0

아직 댓글이 없습니다.