DB·SQL 실무 가이드 · Part 3

조인을 원리로 이해하기

외우는 조인이 아니라 결과 건수를 예측할 수 있는 조인

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

조인의 원리조인의 종류레거시 (+) 구문세미·안티 조인카티션곱계층 쿼리
01

조인은 곱집합에서 조건에 맞는 것만 남기는 것입니다

조인을 종류별로 외우면 결과가 예상과 다를 때 원인을 못 찾습니다. 원리는 단순합니다. 두 테이블의 모든 조합을 만든 뒤 조건에 맞는 행만 남기는 것입니다.

실제로 DB가 그렇게 동작하지는 않습니다. 옵티마이저가 훨씬 효율적인 방법을 씁니다. 하지만 결과를 예측할 때는 이 모델로 생각하는 것이 정확합니다.

여기서 가장 중요한 결론이 나옵니다. 조인하면 행이 늘어날 수 있습니다. 1:N 관계를 조인하면 1쪽 행이 N만큼 복제됩니다. 합계가 이상하게 크게 나오는 리포트의 원인이 대개 이것입니다.

1:1 조인
건수 유지

양쪽 모두 유일한 키로 조인하면 건수가 그대로입니다. 가장 예측하기 쉽습니다.

1:N 조인
건수 증가

고객 1건에 주문 5건이면 결과는 5행이고, 고객 정보가 5번 반복됩니다. 여기서 고객 등급별 합계를 내면 중복 집계됩니다.

N:M 조인
폭증

양쪽 다 중복 키면 곱한 만큼 늘어납니다. 조인 키를 잘못 잡으면 여기로 빠집니다. 결과가 수십 배로 나오면 이걸 의심합니다.

02

조인의 종류와 쓰는 상황

INNER JOIN
양쪽 다 있는 것만

조건에 맞는 짝이 있는 행만 남습니다. 가장 기본이고, 짝 없는 행이 조용히 사라지는 것이 유일한 주의점입니다.

LEFT OUTER JOIN
왼쪽 기준

왼쪽은 모두 남기고 오른쪽은 짝이 없으면 NULL을 채웁니다. '주문이 없는 고객도 포함해서' 같은 요구사항에 씁니다.

FULL OUTER JOIN
양쪽 다 보존

양쪽 모두 짝 없는 행까지 남깁니다. 두 시스템의 데이터를 대사할 때 차이를 찾는 용도로 유용합니다.

CROSS JOIN
의도적 곱집합

모든 조합을 만듭니다. 날짜 목록과 코드 목록을 곱해 빈 구간까지 채운 리포트를 만들 때 의도적으로 씁니다.

아우터 조인에서 조건 위치가 결과를 바꿉니다sql
-- 취소가 아닌 주문만 붙이되, 주문 없는 고객도 남기고 싶다
-- 올바름: 조인 조건에 넣습니다
SELECT c.customer_nm, o.order_id
FROM   customer c
LEFT JOIN orders o
  ON  o.customer_id = c.customer_id
  AND o.status_cd <> 'CANCEL';

-- 틀림: WHERE로 빼면 아우터 조인이 무력화됩니다
SELECT c.customer_nm, o.order_id
FROM   customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE  o.status_cd <> 'CANCEL';  --> 주문 없는 고객은 o.status_cd 가 NULL이라 탈락

아우터 조인에서 오른쪽 테이블 조건을 WHERE에 두면 INNER JOIN과 같아집니다. 실무에서 자주 나오는 실수라 조인 조건과 필터 조건을 의식적으로 구분합니다.

03

레거시 (+) 구문을 읽을 줄 알아야 합니다

ANSI 표준 조인 문법이 나오기 전 Oracle은 WHERE 절에 (+)를 붙여 아우터 조인을 표현했습니다. 오래된 시스템의 쿼리는 대부분 이 형태이고, 지금도 유지보수에서 계속 만납니다.

새로 쓸 때는 ANSI 문법을 권합니다. (+)는 표현할 수 없는 조인이 있고, 조인 조건과 필터 조건이 한 곳에 섞여 읽기 어렵습니다.

FULL OUTER 불가
(+) 한계

양쪽에 (+)를 동시에 붙일 수 없어 FULL OUTER JOIN을 표현하지 못합니다.

OR와 IN 제약
(+) 한계

(+)가 붙은 조건은 OR로 연결하거나 서브쿼리와 함께 쓰는 데 제약이 있습니다.

섞어 쓰지 않기
주의

한 쿼리에서 (+)와 ANSI 문법을 섞으면 오류가 납니다. 기존 쿼리를 고칠 때는 한쪽으로 통일합니다.

같은 뜻의 두 표현sql
-- 레거시 (+) 구문
SELECT c.customer_nm, o.order_id
FROM   customer c, orders o
WHERE  o.customer_id(+) = c.customer_id
AND    o.status_cd(+) <> 'CANCEL';

-- ANSI 표준
SELECT c.customer_nm, o.order_id
FROM   customer c
LEFT JOIN orders o
  ON  o.customer_id = c.customer_id
  AND o.status_cd <> 'CANCEL';

(+)는 NULL을 채워 넣을 쪽에 붙입니다. 위에서는 orders 쪽 모든 조건에 (+)를 빠짐없이 붙여야 하고, 하나라도 빠뜨리면 INNER JOIN이 됩니다.

04

있는지만 확인할 때는 세미·안티 조인을 씁니다

'주문이 있는 고객'을 찾을 때 조인을 쓰면 주문 건수만큼 고객이 중복됩니다. 그래서 DISTINCT를 붙이게 되는데, 이건 불필요한 작업을 한 뒤 걷어내는 방식이라 비효율적입니다.

존재 여부만 필요하면 EXISTS를 씁니다. 첫 번째 짝을 찾는 순간 멈추기 때문에 빠릅니다. 반대로 없는 것을 찾을 때는 NOT EXISTS를 씁니다.

존재 여부 확인의 세 가지 방법sql
-- 1) 조인 + DISTINCT : 중복을 만든 뒤 제거 (비효율)
SELECT DISTINCT c.customer_id, c.customer_nm
FROM   customer c
JOIN   orders o ON o.customer_id = c.customer_id;

-- 2) EXISTS : 첫 짝을 찾으면 멈춤 (권장)
SELECT c.customer_id, c.customer_nm
FROM   customer c
WHERE  EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- 3) IN : 서브쿼리 결과가 작을 때 유리
SELECT c.customer_id, c.customer_nm
FROM   customer c
WHERE  c.customer_id IN (SELECT o.customer_id FROM orders o);

EXISTS 안의 SELECT 목록은 아무 의미가 없어서 관례적으로 1을 씁니다. 무엇을 쓰든 성능 차이는 없습니다.

없는 것 찾기sql
-- 주문이 한 건도 없는 고객
SELECT c.customer_id, c.customer_nm
FROM   customer c
WHERE  NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- 아우터 조인으로도 가능하지만 의도가 덜 드러납니다
SELECT c.customer_id, c.customer_nm
FROM   customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE  o.order_id IS NULL;

Part 2에서 다룬 대로 NOT IN 은 NULL이 섞이면 결과가 통째로 사라집니다. 안티 조인은 NOT EXISTS 를 기본으로 씁니다.

05

조인 조건을 빠뜨리면 카티션곱이 됩니다

테이블 세 개를 조인하려면 조인 조건이 최소 두 개 필요합니다. 하나라도 빠지면 그 테이블은 모든 행과 조합됩니다.

50만 건과 1만 건이 조건 없이 만나면 50억 행입니다. 쿼리가 끝나지 않거나 임시 공간이 터지는 형태로 나타납니다.

  • 테이블이 N개면 조인 조건은 최소 N-1개인지 세어 봅니다.
  • 결과 건수가 예상보다 이상하게 많으면 먼저 조인 조건부터 확인합니다.
  • 실행계획에 MERGE JOIN CARTESIAN 이 보이면 조건이 빠진 것입니다.
  • 의도한 곱집합이라면 CROSS JOIN 이라고 명시해 의도를 드러냅니다.
06

계층 구조는 CONNECT BY로 조회합니다

조직도, 메뉴 트리, 부품 구성처럼 자기 자신을 참조하는 구조는 조인으로 풀기 어렵습니다. 깊이를 모르기 때문입니다.

Oracle은 CONNECT BY 로 이걸 직접 지원합니다. 표준 SQL의 재귀 WITH 절보다 짧고, 레거시 코드에서 아주 흔하게 만납니다. Tibero도 같은 문법을 지원합니다.

계층 쿼리 기본형sql
SELECT LEVEL,
       LPAD(' ', (LEVEL - 1) * 2) || dept_nm AS dept_tree,
       SYS_CONNECT_BY_PATH(dept_nm, '/') AS full_path
FROM   department
START WITH parent_id IS NULL      -- 최상위에서 시작
CONNECT BY PRIOR dept_id = parent_id  -- 부모의 id가 자식의 parent_id
ORDER SIBLINGS BY dept_nm;

LEVEL 은 현재 깊이, PRIOR 는 부모 행을 가리킵니다. PRIOR 를 반대로 붙이면 자식에서 부모로 거슬러 올라가는 조회가 됩니다.

이관 시 주의

CONNECT BY 는 Oracle과 Tibero의 고유 문법입니다. PostgreSQL이나 MySQL로 옮길 때는 재귀 CTE(WITH RECURSIVE)로 다시 써야 하고, 단순 치환이 안 되는 대표적인 부분입니다.

체크

이 파트 완료 기준