SQL 쿼리를 작성하다 보면 조건이 복잡해지고 여러 단계의 가공이 필요할 때가 있습니다.
이때 우리는 쿼리 안에 또 다른 쿼리를 넣게 되는데 가장 익숙한 방법이 서브쿼리(Subquery)이고 최근 실무에서 권장되는 방식이 CTE입니다.
오늘은 이 두 방식의 차이점을 알아보고 왜 실무에서는 CTE를 선호하는지 비교해 보겠습니다.
서브쿼리는 하나의 쿼리 안에 포함된 또 다른 SELECT문을 말합니다.
보통 FROM절이나 WHERE절에 위치하며 필요할 때마다 즉석에서 정의해서 사용합니다.
특징: 별도의 선언 없이 어디서든 사용 가능
단점: 쿼리가 깊어질수록 전체 구조를 파악하기 어렵고 가독성이 떨어짐
-- 서브쿼리 예시: 부서별 평균 급여보다 많이 받는 직원 조회
SELECT EmployeeName, Salary
FROM Employees
WHERE Salary > (
SELECT AVG(Salary)
FROM Employees -- 여기서 서브쿼리 사용
);
간단하게 다른테이블 조회한 결과 가지고 그걸 다시 확인하고 싶은테이블에 비교를 많이 하는 편이긴한데...
CTE는 WITH 절을 사용하여 쿼리 상단에 임시 결과 집합을 정의하는 방식입니다. 마치 변수를 선언하듯 이름을 붙여두고 아래 메인 쿼리에서 테이블처럼 호출합니다.
특징: 쿼리 상단에 로직을 분리하여 정의
장점: 복잡한 로직을 단계별로 작성할 수 있어 가독성이 매우 뛰어남
-- CTE 예시: 부서별 평균 급여보다 많이 받는 직원 조회
WITH DeptAvgSalary AS (
SELECT AVG(Salary) AS AvgSal
FROM Employees
)
SELECT E.EmployeeName, E.Salary
FROM Employees E, DeptAvgSalary D
WHERE E.Salary > D.AvgSal;
처음 본사람이면 아래가 더 복잡해 보일수도 있는데
전체 평균보다 매출이 높은 우수 사원 명단 뽑기을 뽑아야 한다고 한다면
1.사원별 총 매출 계산
2.그 매출들의 평균값 계산
3.평균보다 높은 사원 정보와 매출 결합
서브쿼리 방식은 로직이 깊어질수록 안쪽에서 바깥쪽으로 코드를 읽어야 합니다.
똑같은 집계 로직을 두 번 반복해서 써야 하는 비효율이 발생합니다.
-- 서브쿼리 방식: 가독성 저하와 로직 중복
SELECT
E.EmployeeName,
E.Department,
EmpSales.TotalAmount
FROM Employees E
JOIN (
-- 1단계: 사원별 매출 합계
SELECT EmployeeID, SUM(SalesAmount) AS TotalAmount
FROM Sales
GROUP BY EmployeeID
) AS EmpSales ON E.EmployeeID = EmpSales.EmployeeID
WHERE EmpSales.TotalAmount > (
-- 2단계: 사원별 매출 합계의 '평균'을 구함
SELECT AVG(SubSales.TotalAmount)
FROM (
-- 여기서 또 매출 합계 로직이 반복됨 (비효율)
SELECT SUM(SalesAmount) AS TotalAmount
FROM Sales
GROUP BY EmployeeID
) AS SubSales
);
볼려면 볼손있지만 너무 복잡하고 같은 로직이 반복된다.
CTE를 사용하면 1단계 2단계를 변수처럼 선언해두고 마지막에 조합만 하면 됩니다. 훨씬 논리적이고 깔끔합니다.
-- CTE 방식: 단계별 정의와 재사용
WITH EmpSales AS (
-- 1단계: 사원별 매출 합계 정의
SELECT EmployeeID, SUM(SalesAmount) AS TotalAmount
FROM Sales
GROUP BY EmployeeID
),
AvgSales AS (
-- 2단계: 위에서 만든 EmpSales를 재사용해서 평균 계산
SELECT AVG(TotalAmount) AS OverallAvg
FROM EmpSales
)
-- 3단계: 메인 쿼리에서 조립
SELECT
E.EmployeeName,
E.Department,
S.TotalAmount
FROM Employees E
JOIN EmpSales S ON E.EmployeeID = S.EmployeeID
CROSS JOIN AvgSales A -- 전체 평균값을 가져오기 위한 조인
WHERE S.TotalAmount > A.OverallAvg;
재사용성: EmpSales라는 가상 테이블을 한 번 만들어두고 AvgSales에서도 쓰고 메인 쿼리에서도 씁니다. 로직 중복이 없습니다.
가독성: 쿼리를 위에서 아래로 읽기만 해도 매출 구하고 -> 평균 내고 -> 필터링 라고 바로 이해됩니다.
디버깅 용이: 로직이 꼬였다면 중간의 CTE(EmpSales)만 따로 떼서 조회해 볼 수 있습니다.
서브쿼리: 안쪽에서 바깥쪽으로 읽어야 합니다. 쿼리가 3중 4중으로 겹치면 가독성이 떨어집니다.
CTE: 위에서 아래로 흐르듯 읽힙니다. 1단계로 이 데이터를 뽑고 2단계로 저 데이터를 합친다. 위에서 아래로 보는식입니다.
서브쿼리: 동일한 서브쿼리가 여러 번 필요하다면 매번 복사해서 붙여넣어야 합니다.
CTE: 한 번 정의한 CTE는 메인 쿼리 내에서 여러 번 참조(JOIN 등)할 수 있습니다.
서브쿼리: 자기 자신을 참조하는 재귀적 구조를 만들 수 없습니다.
CTE: WITH RECURSIVE를 통해 조직도나 카테고리 계층 구조 같은 계층형 데이터를 처리할 수 있는 유일한 방법입니다.
| 비교 항목 | 서브쿼리 (Subquery) | CTE (Common Table Expression) |
|---|---|---|
| 코드 가독성 | 낮음 (중첩될수록 복잡) | 높음 (단계별 정의 가능) |
| 재사용성 | 불가능 (반복 작성 필요) | 가능 (이름으로 재참조 가능) |
| 복잡도 | 단순한 필터링에 적합 | 복잡한 로직, 통계에 적합 |
| 재귀 호출 | 지원 안 함 | 지원 함 (Recursive CTE) |
무조건 CTE가 정답인가요? 라고 묻는다면 상황에 따라 다릅니다.
단순한 조건 필터링: WHERE절에 짧게 들어가는 서브쿼리는 오히려 더 빠르고 간결할 수 있습니다.
데이터 가공 단계가 2단계 이상일 때: 무조건 CTE를 추천합니다. 나중에 쿼리를 수정하거나 다른 사람이 코드를 읽을 때 시간을 수십 배 절약해 줍니다.
성능 이슈: 과거에는 CTE가 성능이 떨어진다는 의견도 있었으나 최신 DBMS(PostgreSQL, MSSQL, Oracle 등)의 옵티마이저는 CTE와 서브쿼리를 거의 동일하게 최적화하므로 성능 차이는 미미합니다.