BTP

FULL OUTER vs LEFT JOIN — HANA 언제 쓰나 #shorts #SAP #HANA

▶ YouTube에서 보기

개요와 이 글에서 얻어갈 것

FULL OUTER JOIN은 SQL의 조인 종류 중에서도 가장 늦게 손이 가지만, 반대로 한 번 제대로 쓰면 다른 조인으로 풀기 어려운 문제를 단번에 해결해 주는 도구입니다. 특히 SAP HANA처럼 컬럼 스토어 기반 인메모리 엔진에서는 FULL OUTER JOIN의 실행 계획이 INNER JOIN과 상당히 다르게 풀리며, 잘못 쓰면 메모리 사용량이 수십 배로 튀기도 합니다. 이 글에서는 영업 주문과 출하 데이터의 정합성 점검이라는 실무 시나리오를 통해 FULL OUTER JOIN의 필요성, 동작 원리, 성능 함정을 다룹니다.

  • FULL OUTER JOIN이 실제로 필요한 비즈니스 시나리오 구분
  • NULL 처리와 COALESCE / IS NULL 패턴 익히기
  • HANA 컬럼 엔진에서 FULL OUTER JOIN이 풀리는 방식 이해
  • UNION ALL 기반 대체 패턴과 MERGE 활용 비교
  • 대용량 처리 시 파티셔닝, 필터 푸시다운, 통계 영향 점검

읽기 전에 알고 있으면 좋은 것

이 글은 INNER JOIN, LEFT/RIGHT OUTER JOIN의 결과 차이를 알고 있는 분을 대상으로 합니다. SQL의 NULL 비교 규칙(NULL = NULL이 FALSE라는 점), CASE WHEN, COALESCE 함수의 동작, 그리고 HANA Studio 또는 SAP HANA Database Explorer에서 PlanViz로 실행 계획을 열어 보는 방법 정도를 알고 있으면 본문 이해가 매끄럽습니다. ABAP CDS의 association이 내부적으로 OUTER JOIN으로 풀린다는 점도 함께 떠올려 두면 좋습니다.

실습 환경과 준비물

본 글의 예제는 SAP HANA Cloud(2026 QRC 기준) 및 SAP HANA 2.0 SPS 07 환경에서 동작 확인했습니다. 온프레미스 HANA의 경우 SPS 05 이상이면 문법상 차이가 없습니다. 클라이언트는 SAP HANA Database Explorer 또는 DBeaver 23.x를 사용했고, 권한은 본인 스키마에 CREATE TABLE, INSERT, SELECT가 가능한 사용자면 충분합니다. 컬럼 스토어 기본 옵션이 켜진 상태를 가정하므로 별도의 STORAGE TYPE 지정은 생략합니다.

예제 데이터는 영업 주문 헤더(SalesOrderHeader)와 출하 헤더(DeliveryHeader) 두 테이블을 사용합니다. 주문은 들어왔는데 출하가 없는 건, 출하는 발생했는데 매핑되는 주문이 없는 건, 양쪽 모두 매칭되는 건의 세 가지 케이스를 한 화면에 보기 위함입니다.

핵심 개념: 왜 FULL OUTER JOIN이 따로 있는가

INNER JOIN은 두 집합의 교집합, LEFT OUTER JOIN은 왼쪽 전체에 오른쪽 매칭값을 덧붙이는 것, RIGHT는 그 반대입니다. 그렇다면 FULL OUTER JOIN은 무엇일까요. 단순히 정의하면 두 집합의 합집합이며, 매칭되지 않은 행은 반대편 컬럼이 NULL로 채워집니다.

비유하자면 두 명의 사람이 각각 들고 있는 명단을 하나의 표로 합치는 작업입니다. A의 명단에만 있는 사람, B의 명단에만 있는 사람, 양쪽에 모두 있는 사람을 한 번에 보고 싶을 때 쓰는 것이죠. LEFT JOIN을 두 번 UNION하는 방법도 있지만, 매칭 조건이 복잡하거나 데이터가 클수록 한 번의 FULL OUTER JOIN으로 처리하는 편이 가독성과 성능 모두 유리한 경우가 많습니다.

HANA에서 FULL OUTER JOIN은 Join 연산자(JOIN node) 내부에서 두 입력을 모두 보존해야 하므로, 일반적으로 해시 조인으로 풀립니다. 한쪽을 빌드 사이드로 잡고 매칭 비트맵을 유지한 다음, 매칭되지 않은 행을 양쪽에서 따로 뽑아 결과에 덧붙이는 식입니다. 이 때문에 입력 데이터 자체가 크면 빌드 단계의 메모리 사용량이 커지고, 컬럼 엔진의 필터 푸시다운 효과가 INNER JOIN만큼 적극적으로 적용되지 않을 수 있습니다.

한 줄 요약: INNER JOIN이 교집합, OUTER JOIN이 한쪽 전부 살리기라면, FULL OUTER JOIN은 양쪽 전부 살리기. 단, 양쪽 모두 보존해야 한다는 제약이 성능에 그대로 반영된다.

1단계: 가장 단순한 FULL OUTER JOIN 예제

먼저 테이블과 샘플 데이터를 만듭니다. 주문은 5건, 출하는 4건이며 일부 주문은 출하가 없고 일부 출하는 잘못된 주문번호로 들어와 있다고 가정합니다.

CREATE COLUMN TABLE SalesOrderHeader (
  OrderId      NVARCHAR(10) PRIMARY KEY,
  CustomerCode NVARCHAR(10),
  OrderAmount  DECIMAL(15,2),
  OrderDate    DATE
);

CREATE COLUMN TABLE DeliveryHeader (
  DeliveryId   NVARCHAR(10) PRIMARY KEY,
  OrderId      NVARCHAR(10),
  ShipDate     DATE,
  ShipQty      INTEGER
);

INSERT INTO SalesOrderHeader VALUES ('SO-1001','C001',1200.00,'2026-06-01');
INSERT INTO SalesOrderHeader VALUES ('SO-1002','C002', 800.00,'2026-06-02');
INSERT INTO SalesOrderHeader VALUES ('SO-1003','C003', 450.00,'2026-06-03');
INSERT INTO SalesOrderHeader VALUES ('SO-1004','C001', 900.00,'2026-06-04');
INSERT INTO SalesOrderHeader VALUES ('SO-1005','C004',1500.00,'2026-06-05');

INSERT INTO DeliveryHeader  VALUES ('DL-9001','SO-1001','2026-06-03',10);
INSERT INTO DeliveryHeader  VALUES ('DL-9002','SO-1002','2026-06-04', 5);
INSERT INTO DeliveryHeader  VALUES ('DL-9003','SO-1004','2026-06-06', 7);
INSERT INTO DeliveryHeader  VALUES ('DL-9004','SO-9999','2026-06-07', 3);

이제 주문과 출하를 OrderId 기준으로 합쳐 보겠습니다.

SELECT
  s.OrderId      AS OrderIdFromOrder,
  d.OrderId      AS OrderIdFromDelivery,
  s.CustomerCode,
  s.OrderAmount,
  d.DeliveryId,
  d.ShipQty
FROM SalesOrderHeader s
FULL OUTER JOIN DeliveryHeader d
  ON s.OrderId = d.OrderId
ORDER BY COALESCE(s.OrderId, d.OrderId);

결과를 보면 SO-1003, SO-1005는 출하 컬럼이 NULL로, DL-9004는 주문 컬럼이 NULL로 나옵니다. 이 한 번의 쿼리로 양쪽 어디에서든 누락된 행을 식별할 수 있다는 점이 핵심입니다. ORDER BY 절에 COALESCE를 사용한 이유는 NULL 정렬을 피하면서 양쪽 키를 일관되게 정렬하기 위함입니다.

2단계: 정합성 점검 리포트로 확장하기

실무에서는 단순 조회보다 "누가 빠졌고, 누가 잘못 들어왔는지" 분류해서 리포트하는 형태가 더 자주 필요합니다. 상태 컬럼을 추가하고, 출하 지연 일수를 계산하며, 잘못된 매핑은 별도로 표시해 봅니다.

SELECT
  COALESCE(s.OrderId, d.OrderId)              AS KeyOrderId,
  s.CustomerCode,
  s.OrderAmount,
  d.DeliveryId,
  d.ShipQty,
  CASE
    WHEN s.OrderId IS NOT NULL AND d.OrderId IS NOT NULL THEN 'MATCHED'
    WHEN s.OrderId IS NOT NULL AND d.OrderId IS NULL     THEN 'NOT_SHIPPED'
    WHEN s.OrderId IS NULL     AND d.OrderId IS NOT NULL THEN 'ORPHAN_DELIVERY'
  END                                          AS MatchStatus,
  CASE
    WHEN s.OrderDate IS NOT NULL AND d.ShipDate IS NOT NULL
      THEN DAYS_BETWEEN(s.OrderDate, d.ShipDate)
    ELSE NULL
  END                                          AS ShipLeadDays
FROM SalesOrderHeader s
FULL OUTER JOIN DeliveryHeader d
  ON s.OrderId = d.OrderId
WHERE s.OrderId IS NULL
   OR d.OrderId IS NULL
   OR DAYS_BETWEEN(s.OrderDate, d.ShipDate) > 3
ORDER BY MatchStatus, KeyOrderId;

WHERE 절에 IS NULL 조건이 들어간 점을 눈여겨봐 주세요. FULL OUTER JOIN 결과에 일반 등호 비교(s.OrderId = 'SO-1003')를 걸면 NULL 행이 자동으로 떨어져 나가므로, "누락 검출" 목적과 정반대 결과가 나옵니다. 또한 DAYS_BETWEEN의 인자가 NULL이면 결과도 NULL이므로 OR 조건의 마지막에 와도 안전합니다.

이 단계에서 자주 발생하는 실수가 한 가지 있습니다. 결과를 CDS View나 Calculation View에서 다시 가공할 때, MatchStatus 컬럼이 NULL이 되는 경우를 빼먹는 것입니다. CASE 문에 ELSE를 명시하거나, NOT NULL 제약을 두지 않은 컬럼으로 저장하는 편을 권장합니다.

3단계: 프로덕션에서의 성능 튜닝과 MERGE 전환

주문이 수천만 건, 출하가 수억 건 단위로 쌓이는 운영 환경이라면 같은 쿼리도 전혀 다르게 다뤄야 합니다. 다음은 운영 단계에서 일반적으로 권장되는 패턴입니다.

-- 필터를 먼저 적용한 인라인 뷰로 좁힌 뒤 FULL OUTER JOIN
WITH RecentOrders AS (
  SELECT OrderId, CustomerCode, OrderAmount, OrderDate
  FROM SalesOrderHeader
  WHERE OrderDate >= ADD_DAYS(CURRENT_DATE, -30)
),
RecentDeliveries AS (
  SELECT DeliveryId, OrderId, ShipDate, ShipQty
  FROM DeliveryHeader
  WHERE ShipDate >= ADD_DAYS(CURRENT_DATE, -30)
)
SELECT
  COALESCE(o.OrderId, d.OrderId) AS OrderKey,
  o.CustomerCode,
  o.OrderAmount,
  d.DeliveryId,
  d.ShipQty
FROM RecentOrders o
FULL OUTER JOIN RecentDeliveries d
  ON o.OrderId = d.OrderId;

FULL OUTER JOIN은 옵티마이저가 필터를 양쪽으로 푸시다운하기 어려운 경우가 있습니다. 위처럼 WITH 절이나 인라인 서브쿼리로 양쪽 입력을 미리 좁혀 두면, 조인 단계로 들어오는 행 수가 줄어 메모리 사용량과 응답 시간이 함께 줄어드는 경우가 많습니다.

두 번째로, 정합성 보정이 목적이라면 SELECT가 아니라 MERGE를 검토하시기를 권장합니다. FULL OUTER JOIN으로 차이를 찾은 다음 별도 DML로 반영하는 두 단계 처리보다, 한 번에 끝낼 수 있습니다.

MERGE INTO DeliveryReconciliation t
USING (
  SELECT
    COALESCE(s.OrderId, d.OrderId) AS OrderKey,
    s.OrderAmount,
    d.ShipQty,
    CASE
      WHEN s.OrderId IS NULL THEN 'ORPHAN_DELIVERY'
      WHEN d.OrderId IS NULL THEN 'NOT_SHIPPED'
      ELSE 'MATCHED'
    END AS MatchStatus
  FROM SalesOrderHeader s
  FULL OUTER JOIN DeliveryHeader d
    ON s.OrderId = d.OrderId
) src
ON  t.OrderKey = src.OrderKey
WHEN MATCHED THEN UPDATE SET
  t.OrderAmount  = src.OrderAmount,
  t.ShipQty      = src.ShipQty,
  t.MatchStatus  = src.MatchStatus,
  t.UpdatedAt    = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT
  (OrderKey, OrderAmount, ShipQty, MatchStatus, UpdatedAt)
VALUES
  (src.OrderKey, src.OrderAmount, src.ShipQty, src.MatchStatus, CURRENT_TIMESTAMP);

마지막으로 실행 계획 확인입니다. SAP HANA Database Explorer에서 Explain Plan 또는 PlanViz를 열고, 다음 항목을 점검하시기를 권장합니다.

  • Join 노드의 JOIN TYPE이 FULL OUTER로 나오는지(LEFT나 INNER로 변환되었으면 옵티마이저가 더 효율적인 경로를 선택한 것)
  • 양쪽 입력 행 수(ROW COUNT)와 빌드 사이드 메모리(MEM USED)
  • 필터가 조인 위로 푸시업되었는지, 조인 아래로 푸시다운되었는지
  • 통계가 오래되어 잘못된 카디널리티 추정이 나오지 않는지(MERGE DELTA 이후 UPDATE STATISTICS 실행 권장)

현장에서 자주 마주치는 함정과 해결

Q1. FULL OUTER JOIN 결과에 WHERE 절을 걸었더니 LEFT JOIN과 결과가 같아졌습니다. WHERE s.SomeCol = 'X' 처럼 한쪽 테이블 컬럼에 등호 조건을 걸면 NULL 행이 모두 제거되어 사실상 INNER JOIN이 됩니다. 누락 행을 살리려면 OR s.SomeCol IS NULL을 함께 쓰거나, 필터를 ON 절로 옮기시기 바랍니다.

Q2. 컬럼이 NULL인지 아닌지 헷갈립니다. 결과 셋에서 양쪽 키가 살아 있는지 빠르게 보려면 COALESCE(s.OrderId, d.OrderId)로 통합 키 컬럼을 별도로 만들고, 원본 두 키를 그대로 노출하는 패턴이 가독성에 좋습니다. Calculation View로 옮길 때도 통합 키가 있으면 후속 조인이 단순해집니다.

Q3. 운영에서 갑자기 느려졌습니다. 가장 흔한 원인은 통계가 낡았거나, 한쪽 입력이 폭증해 해시 빌드가 디스크로 스필되는 경우입니다. M_CS_TABLES, M_EXPENSIVE_STATEMENTS 뷰에서 메모리 사용량과 실행 시간을 확인하고, 필요하면 입력을 좁히는 인라인 뷰를 추가하시기를 권장합니다. 또한 두 테이블의 파티셔닝 키가 조인 키와 다르면 셔플 비용이 큽니다. 가능하다면 OrderId 기준 해시 파티셔닝을 검토해 볼 만합니다.

Q4. UNION ALL 두 번이 더 빠르다는 글을 봤습니다. 데이터가 작고 한쪽이 매우 선택도가 높을 때는 LEFT JOIN + WHERE IS NULL을 두 번 UNION하는 방식이 빠를 수 있습니다. 다만 같은 데이터를 두 번 스캔하기 때문에 일반적으로는 한 번의 FULL OUTER JOIN이 안정적입니다. 실제 데이터로 EXPLAIN을 떠 보고 선택하시기를 권장합니다.

여기서 더 들어가 볼만한 주제

FULL OUTER JOIN을 익혔다면 다음 주제로 자연스럽게 확장할 수 있습니다. 첫째, Calculation View의 Star Join과 Outer Join 처리 방식 차이입니다. 둘째, ABAP CDS의 LEFT OUTER TO ONE JOIN과 association 동작이 HANA 실행 계획에서 어떻게 풀리는지 확인하는 것입니다. 셋째, SQLScript의 테이블 변수와 결합한 점진적 정합성 점검 프로시저를 만들어 두면 야간 배치로 활용하기 좋습니다. 마지막으로, HANA Cloud의 Replication Task에서 발생하는 누락 데이터를 FULL OUTER JOIN 기반의 점검 뷰로 모니터링하는 패턴도 실무에서 자주 쓰입니다.

댓글 0

아직 댓글이 없습니다.