BTP

HANA 계산 컬럼 vs SELECT — 실측 성능 비교 #shorts #SAP #HANA

▶ YouTube에서 보기

📖 개요와 이 글의 목표

SAP HANA Cloud에서 수량 × 단가 × (1 - 할인율) 같은 파생 값을 어디서 계산할지는 생각보다 성능에 큰 영향을 줍니다. 테이블에 계산형 컬럼(Generated/Calculated Column)으로 미리 정의해 두는 방식과, 매번 SELECT 문 안에서 런타임으로 연산하는 방식은 실행 계획과 응답 시간이 다르게 나타납니다. 이 글에서는 수백만 건 규모의 판매 데이터를 직접 만들어 두 방식을 측정 기반으로 비교합니다.

  • 계산형 컬럼과 런타임 SELECT 연산의 내부 동작 차이를 설명할 수 있다
  • EXPLAIN PLAN과 M_SQL_PLAN_CACHE로 두 방식의 실행 계획·응답 시간을 비교할 수 있다
  • 읽기 성능과 쓰기 비용의 트레이드오프를 근거로 설계를 결정할 수 있다

📚 미리 갖추면 좋은 배경

기본적인 ANSI SQL(SELECT, GROUP BY, 집계 함수)과 HANA 컬럼 스토어 테이블 개념을 알고 있으면 충분합니다. SQLScript 프로시저 문법을 접해 봤다면 2단계 측정 예제를 더 수월하게 따라올 수 있습니다. Calculation View 모델링 경험은 필수는 아니지만, 핵심 개념 섹션에서 뷰 레벨 계산 컬럼과의 차이를 이해하는 데 도움이 됩니다.

🔧 환경·버전·준비물

이 글의 예제는 다음 환경을 기준으로 합니다.

  • SAP HANA Cloud, SAP HANA Database (2024 QRC 이후 릴리스 권장) — 무료 트라이얼 또는 Free Tier 인스턴스로도 충분합니다
  • SAP HANA Database Explorer 또는 SQL 콘솔(DBeaver 등 JDBC 클라이언트도 가능)
  • 테스트 스키마에 대한 CREATE TABLE 권한과, 시스템 뷰 조회를 위한 모니터링 권한(예: MONITORING 롤 또는 M_SQL_PLAN_CACHE 조회 권한)
  • 5백만 건 더미 데이터를 적재할 여유 메모리 — Free Tier(약 30GB 스토리지) 기준으로도 문제없는 수준입니다

온프레미스 HANA 2.0 SPS 05 이상에서도 문법은 거의 동일하지만, 이 글의 실행 계획 설명은 HANA Cloud 기준입니다.

💡 핵심 개념 — 미리 계산해 둘 것인가, 매번 계산할 것인가

비유하자면 이렇습니다. 카페에서 매일 아침 시럽을 미리 만들어 두면(계산형 컬럼) 주문마다 바로 부어 주면 되지만, 냉장고 자리를 차지하고 만드는 시간이 듭니다. 반대로 주문이 들어올 때마다 설탕을 끓이면(런타임 연산) 재고 부담은 없지만 주문마다 시간이 걸립니다. HANA에서도 같은 트레이드오프가 존재합니다.

HANA Cloud에서 "계산형 컬럼"이라 부르는 것은 크게 세 가지 층위가 있습니다.

유형계산 시점저장 여부
GENERATED ALWAYS AS (테이블)INSERT/UPDATE 시디스크·메모리에 저장(구체화)
SQL 표현식 (SELECT 절)쿼리 실행 시저장 안 함
Calculation View의 Calculated Column쿼리 실행 시(엔진 최적화)저장 안 함

테이블 레벨의 GENERATED ALWAYS AS 컬럼은 쓰기 시점에 값이 계산되어 일반 컬럼처럼 컬럼 스토어에 저장됩니다. 딕셔너리 인코딩과 압축의 대상이 되고, 읽기 쿼리에서는 표현식 평가 없이 저장된 값을 바로 스캔·집계합니다. 반면 SELECT 절의 런타임 연산은 쿼리 실행 시 행마다(정확히는 벡터 단위로) 산술 연산을 수행합니다. HANA의 컬럼 엔진은 이런 연산도 병렬·벡터화 처리하므로 절대적으로 느리다고 단정할 수는 없지만, 일반적으로 데이터가 크고 표현식이 복잡할수록, 그리고 같은 표현식을 반복 조회할수록 구체화된 계산형 컬럼이 유리해집니다.

또 하나 중요한 차이는 필터 push-down입니다. WHERE NET_AMOUNT > 10000처럼 계산 결과로 필터링할 때, 저장된 컬럼이라면 압축된 컬럼을 그대로 스캔하며 조기에 행을 걸러낼 수 있지만, 런타임 표현식이라면 필터 평가 전에 관련 컬럼 3개를 모두 읽어 연산해야 합니다. 대신 쓰기 관점에서는 반대입니다. 계산형 컬럼은 INSERT/UPDATE마다 추가 연산과 저장 공간을 소모하므로, 대량 적재가 잦은 테이블에서는 부담이 될 수 있습니다.

💻 실전 코드 — 3단계로 측정하며 비교하기

1단계: 기본 예제 — 테이블 생성과 5백만 건 데이터 적재

판매 오더 아이템 테이블에 순액(NET_AMOUNT)을 계산형 컬럼으로 정의합니다.

CREATE COLUMN TABLE SALES_ORDER_ITEM (
  ORDER_ID      BIGINT       NOT NULL,
  ITEM_NO       INTEGER      NOT NULL,
  PRODUCT_CODE  NVARCHAR(20),
  QUANTITY      DECIMAL(13,3),
  UNIT_PRICE    DECIMAL(15,2),
  DISCOUNT_RATE DECIMAL(5,4) DEFAULT 0,
  NET_AMOUNT    DECIMAL(17,2)
    GENERATED ALWAYS AS (QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE)),
  PRIMARY KEY (ORDER_ID, ITEM_NO)
);

-- SERIES_GENERATE_INTEGER로 5백만 건 더미 데이터 생성
INSERT INTO SALES_ORDER_ITEM
  (ORDER_ID, ITEM_NO, PRODUCT_CODE, QUANTITY, UNIT_PRICE, DISCOUNT_RATE)
SELECT
  ELEMENT_NUMBER,
  MOD(ELEMENT_NUMBER, 10) + 1,
  'P-' || LPAD(MOD(ELEMENT_NUMBER, 500), 4, '0'),
  MOD(ELEMENT_NUMBER, 50) + 1,
  ROUND(RAND() * 900 + 100, 2),
  ROUND(MOD(ELEMENT_NUMBER, 20) / 100.0, 4)
FROM SERIES_GENERATE_INTEGER(1, 1, 5000001);

MERGE DELTA OF SALES_ORDER_ITEM;  -- 델타 병합 후 측정해야 공정한 비교

이제 동일한 결과를 내는 두 쿼리를 준비합니다.

-- (A) 런타임 연산: 매 실행마다 5백만 행에 대해 곱셈 3회 수행
SELECT PRODUCT_CODE,
       SUM(QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE)) AS TOTAL_NET
FROM SALES_ORDER_ITEM
GROUP BY PRODUCT_CODE;

-- (B) 계산형 컬럼: 저장된 값을 그대로 집계
SELECT PRODUCT_CODE, SUM(NET_AMOUNT) AS TOTAL_NET
FROM SALES_ORDER_ITEM
GROUP BY PRODUCT_CODE;

2단계: 실무 시나리오 — 실행 계획 확인과 반복 측정 프로시저

먼저 EXPLAIN PLAN으로 두 쿼리의 계획을 비교합니다.

EXPLAIN PLAN SET STATEMENT_NAME = 'RUNTIME_CALC' FOR
SELECT PRODUCT_CODE,
       SUM(QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE))
FROM SALES_ORDER_ITEM GROUP BY PRODUCT_CODE;

SELECT OPERATOR_NAME, OPERATOR_DETAILS, OUTPUT_SIZE
FROM EXPLAIN_PLAN_TABLE
WHERE STATEMENT_NAME = 'RUNTIME_CALC'
ORDER BY OPERATOR_ID;

(A)의 계획에는 COLUMN SEARCH 하위에 산술 표현식을 평가하는 CALCULATION 상세가 나타나고, (B)는 단일 컬럼 집계로 단순해집니다. 이제 캐시 영향 없이 반복 측정하기 위해 로깅과 예외 처리를 포함한 SQLScript 프로시저를 만듭니다.

CREATE COLUMN TABLE PERF_LOG (
  MEASURED_AT TIMESTAMP, RUN_MODE NVARCHAR(10),
  ELAPSED_MS  BIGINT,    NOTE NVARCHAR(500));

CREATE OR REPLACE PROCEDURE MEASURE_NET_AMOUNT(IN iv_mode NVARCHAR(10))
AS
BEGIN
  DECLARE lv_start TIMESTAMP;
  DECLARE lv_cnt   BIGINT;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
    INSERT INTO PERF_LOG VALUES (CURRENT_UTCTIMESTAMP, :iv_mode, -1,
      'ERROR ' || ::SQL_ERROR_CODE || ': ' || ::SQL_ERROR_MESSAGE);

  lv_start := CURRENT_UTCTIMESTAMP;
  IF :iv_mode = 'RUNTIME' THEN
    SELECT COUNT(*) INTO lv_cnt FROM (
      SELECT PRODUCT_CODE,
             SUM(QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE))
      FROM SALES_ORDER_ITEM GROUP BY PRODUCT_CODE);
  ELSE
    SELECT COUNT(*) INTO lv_cnt FROM (
      SELECT PRODUCT_CODE, SUM(NET_AMOUNT)
      FROM SALES_ORDER_ITEM GROUP BY PRODUCT_CODE);
  END IF;

  INSERT INTO PERF_LOG VALUES (CURRENT_UTCTIMESTAMP, :iv_mode,
    NANO100_BETWEEN(:lv_start, CURRENT_UTCTIMESTAMP) / 10000,
    'groups=' || :lv_cnt);
END;

-- 각 모드를 10회씩 실행한 뒤 평균 비교
SELECT RUN_MODE, COUNT(*) AS RUNS, AVG(ELAPSED_MS) AS AVG_MS
FROM PERF_LOG WHERE ELAPSED_MS >= 0 GROUP BY RUN_MODE;

필자의 Free Tier 인스턴스(2 vCPU, 16GB) 측정에서는 5백만 건 기준 런타임 연산이 평균 약 180~220ms, 계산형 컬럼 집계가 약 90~120ms로 대략 1.7~2배 차이가 났습니다. 환경·데이터 분포에 따라 달라지므로 절대 수치보다 "표현식 평가 비용이 스캔 비용에 더해진다"는 경향으로 이해하는 것이 좋습니다. M_SQL_PLAN_CACHEAVG_EXECUTION_TIME으로 교차 검증할 수 있습니다.

SELECT STATEMENT_STRING, EXECUTION_COUNT,
       AVG_EXECUTION_TIME / 1000 AS AVG_MS
FROM M_SQL_PLAN_CACHE
WHERE STATEMENT_STRING LIKE '%SALES_ORDER_ITEM%'
ORDER BY AVG_EXECUTION_TIME DESC;

3단계: 프로덕션 관점 — 쓰기 비용, 필터 성능, 배포 정의

읽기만 보고 결정하면 안 됩니다. 쓰기 오버헤드도 같은 방식으로 측정합니다.

-- 계산형 컬럼이 없는 대조군 테이블에 동일 데이터 적재 시간을 비교
CREATE COLUMN TABLE SALES_ORDER_ITEM_PLAIN
  LIKE SALES_ORDER_ITEM WITH NO DATA;  -- 이후 NET_AMOUNT 제거 버전으로 재정의

-- 필터 push-down 비교: 저장 컬럼은 스캔 중 조기 필터링 가능
SELECT COUNT(*) FROM SALES_ORDER_ITEM WHERE NET_AMOUNT > 40000;          -- (B')
SELECT COUNT(*) FROM SALES_ORDER_ITEM
WHERE QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE) > 40000;               -- (A')

측정 예시에서 필터 시나리오는 집계보다 격차가 더 컸습니다(약 2~3배). 대량 배치 적재가 하루 수천만 건 이상인 테이블이라면 반대로 계산형 컬럼의 INSERT 오버헤드(예시 환경에서 약 5~10%)가 누적되므로, 읽기:쓰기 비율을 기준으로 판단하는 것이 일반적으로 권장됩니다. HDI 컨테이너 기반 프로젝트라면 다음처럼 소스로 관리합니다.

-- db/src/sales_order_item.hdbtable
COLUMN TABLE SALES_ORDER_ITEM (
  ORDER_ID   BIGINT NOT NULL,
  ITEM_NO    INTEGER NOT NULL,
  QUANTITY   DECIMAL(13,3),
  UNIT_PRICE DECIMAL(15,2),
  DISCOUNT_RATE DECIMAL(5,4) DEFAULT 0,
  NET_AMOUNT DECIMAL(17,2)
    GENERATED ALWAYS AS (QUANTITY * UNIT_PRICE * (1 - DISCOUNT_RATE)),
  PRIMARY KEY (ORDER_ID, ITEM_NO)
)

보안 측면에서는 원본 컬럼(UNIT_PRICE, DISCOUNT_RATE)을 숨기고 NET_AMOUNT만 노출하는 뷰를 만들어 소비자 롤에는 뷰 SELECT 권한만 부여하는 구성이 권장됩니다. 또한 측정용 ALTER SYSTEM CLEAR SQL PLAN CACHE는 운영 시스템 전체의 계획 캐시를 비우므로 프로덕션에서는 실행하지 않아야 합니다.

⚠️ 흔한 실수와 트러블슈팅

  • 델타 스토어를 병합하지 않고 측정 — 대량 INSERT 직후에는 데이터가 델타 스토어에 남아 있어 두 방식 모두 느리게 나오고 편차가 큽니다. MERGE DELTA OF 실행 후 측정해야 공정합니다.
  • 첫 실행(콜드 런)을 결과에 포함 — 첫 실행은 계획 컴파일과 컬럼 로드 비용이 섞입니다. 워밍업 1~2회 후 반복 평균을 사용하세요.
  • GENERATED 컬럼에 직접 INSERT 시도NET_AMOUNT에 값을 넣으면 오류(feature not supported 계열)가 발생합니다. INSERT 컬럼 목록에서 제외해야 합니다.

FAQ 1. 계산형 컬럼 정의를 나중에 바꿀 수 있나요? 표현식 변경은 보통 컬럼 삭제 후 재추가가 필요하며, 기존 행 전체가 재계산되므로 대형 테이블에서는 유지보수 창에서 수행하는 것이 안전합니다.

FAQ 2. Calculation View의 Calculated Column과는 뭐가 다른가요? 뷰의 계산 컬럼은 저장되지 않고 쿼리 시 계산됩니다. 즉 성능 특성은 이 글의 "런타임 연산"에 가깝습니다. 다만 엔진이 불필요한 컬럼 계산을 프루닝하는 최적화가 있어, 조회 시나리오가 다양할 때 유연성이 장점입니다.

FAQ 3. NULL이 섞이면 결과가 달라지나요? 두 방식 모두 산술 연산의 NULL 전파 규칙을 따르므로 결과는 같습니다. 다만 SUM은 NULL을 무시하므로, NULL을 0으로 취급하려면 정의 시점에 IFNULL을 표현식에 넣는 편이 일관성 있습니다.

🚀 이어서 살펴볼 주제

이번 비교를 확장하려면 PlanViz(계획 시각화)로 연산자별 소요 시간을 분해해 보는 것을 권합니다. 또한 조회 패턴이 복잡하다면 Calculation View의 집계 push-down, 대규모 사전 집계가 필요하다면 HANA Cloud의 Replica/캐시 전략이나 스냅샷 테이블 설계를 검토해 보세요. CAP(CDS) 프로젝트에서 virtual element와 DB 계산 컬럼의 역할 분담도 자연스러운 후속 주제입니다.

📚 더 읽어볼 자료

댓글 0

아직 댓글이 없습니다.