을 통해 기본적인 데이터 조회부터 문자열 및 날짜 처리, 다양한 JOIN, 그리고 윈도우 함수의 기초까지 함께 살펴보았습니다.
이번 3편에서는 특히 복잡한 쿼리를 명쾌하게 만드는 CTE(Common Table Expressions)와 쿼리 속의 또 다른 쿼리인 서브쿼리(Subquery)를 집중적으로 파헤치고, 고급 집계 함수 중 일부를 함께 살펴보겠습니다.
복잡한 SQL 쿼리를 작성할 때 가독성을 높이고 구조적으로 만들 수 있도록, 쿼리 내에서 임시로 이름을 가진 결과 집합을 정의하고 사용하는 기능입니다.
CTE는 쿼리를 여러 논리적 단위로 나누어 관리할 수 있게 해주며, 특히 여러 번 참조되거나 복잡한 서브쿼리를 대체할 때 유용합니다.
MySQL
MySQL 8.0 버전부터 CTE를 지원하며, 재귀 CTE의 경우 WITH RECURSIVE 키워드를 사용합니다.
Oracle
CTE를 지원하며, 재귀 CTE도 WITH 키워드만으로 기본적으로 지원합니다 (재귀 호출 시 컬럼명 명시 권장).
MATERIALIZE 또는 INLINE 힌트를 사용하여 CTE의 구체화(임시 테이블 생성) 여부를 제어할 수도 있습니다 (성능 튜닝 시 고려).
예시 1
각 사용자별 총 주문 금액을 계산한 후, 평균 주문 금액 이상인 사용자 찾기
-- UserTotalAmounts CTE: 사용자별 총 주문 금액 계산WITH UserTotalAmounts AS ( SELECT user_id, -- 사용자 ID SUM(amount) AS total_amount -- 해당 사용자의 총 주문 금액 FROM Orders WHERE user_id IS NOT NULL -- user_id가 NULL이 아닌 주문만 대상 GROUP BY user_id -- 사용자 ID로 그룹화),-- AvgTotalAmount CTE: 전체 사용자의 평균 총 주문 금액 계산AvgTotalAmount AS ( -- UserTotalAmounts CTE의 결과를 사용하여 평균 계산 SELECT AVG(total_amount) AS avg_amount FROM UserTotalAmounts)-- 메인 쿼리: 평균 총 주문 금액 이상인 사용자 정보 조회SELECT U.name, -- 사용자 이름 UTA.total_amount -- 사용자의 총 주문 금액FROM Users UJOIN UserTotalAmounts UTA ON U.id = UTA.user_id -- Users 테이블과 UserTotalAmounts CTE를 사용자 ID로 조인WHERE UTA.total_amount >= (SELECT avg_amount FROM AvgTotalAmount);-- AvgTotalAmount CTE의 결과를 서브쿼리로 사용하여 조건 비교
name
total_amount
홍길동
16000
(평균 계산 결과에 따라 달라질 수 있음)
예시 2
여러 CTE를 연결하여 사용하는 경우
-- OrdersByProduct CTE: 상품별 총 주문액과 주문 건수 계산WITH OrdersByProduct AS ( SELECT product, -- 상품명 SUM(amount) AS total_product_amount, -- 상품별 총 주문액 COUNT(id) AS order_count -- 상품별 주문 건수 FROM Orders GROUP BY product),-- UsersWithHighOrderCountProduct CTE: 특정 주문 건수(예: 2건) 이상인 상품을 주문한 사용자 ID 조회UsersWithHighOrderCountProduct AS ( SELECT DISTINCT -- 중복 제거 O.user_id FROM Orders O JOIN OrdersByProduct OP ON O.product = OP.product -- Orders와 OrdersByProduct CTE 조인 WHERE OP.order_count >= 2 AND O.user_id IS NOT NULL -- 주문 건수가 2 이상인 상품을 주문한 사용자)-- 메인 쿼리: 해당 사용자들의 이름과 이메일 조회SELECT U.name, U.emailFROM Users UJOIN UsersWithHighOrderCountProduct UHCOP ON U.id = UHCOP.user_id; -- Users와 두 번째 CTE 조인
계층형 데이터 조회(예: 조직도, 부품 전개, 친구 관계 탐색) 등에 사용되며, 초기 멤버(Anchor Member)와 재귀 멤버(Recursive Member)를 UNION ALL로 연결합니다.
재귀 멤버는 자기 자신 CTE를 참조합니다.
MySQL
WITH RECURSIVE cte_name AS (anchor_member UNION ALL recursive_member)
Oracle
WITH cte_name (col1, col2, ..) AS (anchor_member UNION ALL recursive_member) (컬럼명 명시 권장)
예시
Employees 테이블(2편 예제)에서 특정 직원(예: Alice) 하위의 모든 직원 계층 조회하기 (Employees 테이블: id, name, manager_id)
MySQL
-- EmployeeHierarchy CTE: 직원 계층 구조 조회 (MySQL은 RECURSIVE 명시)WITH RECURSIVE EmployeeHierarchy AS ( -- Anchor Member: 계층의 시작점 (Alice) SELECT id, -- 직원 ID name, -- 직원 이름 manager_id, -- 매니저 ID 0 AS level -- 계층 레벨 (시작은 0) FROM Employees WHERE name = 'Alice' -- 시작 직원 이름 지정 UNION ALL -- Anchor 결과와 Recursive 결과를 합침 -- Recursive Member: 하위 직원들을 재귀적으로 찾아 연결 SELECT e.id, -- 현재 직원 ID e.name, -- 현재 직원 이름 e.manager_id,-- 현재 직원의 매니저 ID eh.level + 1 -- 이전 레벨에 1을 더함 FROM Employees e INNER JOIN EmployeeHierarchy eh ON e.manager_id = eh.id -- 현재 직원의 매니저 ID와 이전 계층의 직원 ID를 조인 (재귀 호출) WHERE eh.level < 5 -- 최대 재귀 깊이 제한 (선택 사항, 무한 루프 방지))-- 최종 결과 조회: 계층 레벨과 ID 순으로 정렬SELECT * FROM EmployeeHierarchy ORDER BY level, id;
Oracle
-- EmployeeHierarchy CTE: 직원 계층 구조 조회 (Oracle은 컬럼 리스트 명시 권장)WITH EmployeeHierarchy (id, name, manager_id, lvl) AS ( -- CTE 컬럼명 명시 -- Anchor Member: 계층의 시작점 (Alice) SELECT id, name, manager_id, 0 AS lvl -- 계층 레벨 (시작은 0) FROM Employees WHERE name = 'Alice' -- 시작 직원 이름 지정 UNION ALL -- Anchor 결과와 Recursive 결과를 합침 -- Recursive Member: 하위 직원들을 재귀적으로 찾아 연결 SELECT e.id, e.name, e.manager_id, eh.lvl + 1 -- 이전 레벨에 1을 더함 FROM Employees e INNER JOIN EmployeeHierarchy eh ON e.manager_id = eh.id -- 현재 직원의 매니저 ID와 이전 계층의 직원 ID를 조인 (재귀 호출) WHERE eh.lvl < 5 -- 최대 재귀 깊이 제한 (선택 사항))-- 최종 결과 조회: 계층 레벨과 ID 순으로 정렬SELECT * FROM EmployeeHierarchy ORDER BY lvl, id;
서브쿼리는 다른 SQL 쿼리 내부에 포함된 또 다른 SELECT 문입니다.
메인 쿼리가 실행되기 전에 한 번 실행되어 그 결과를 메인 쿼리에서 활용하거나(비상관 서브쿼리), 메인 쿼리의 각 행에 대해 반복적으로 실행될 수 있습니다(상관 서브쿼리).
복잡한 조건을 설정하거나, 동적인 데이터를 기반으로 조회할 때 유용하게 사용됩니다.
메인 쿼리의 각 행에 대해 독립적으로 실행되어 값을 반환하며, 이 값은 메인 쿼리의 컬럼처럼 사용됩니다.
예시
각 사용자(Users) 정보와 함께 해당 사용자의 총 주문 횟수를 조회 (주문 없는 사용자는 0회)
SELECT U.name, -- 사용자 이름 U.email, -- 사용자 이메일 -- 스칼라 서브쿼리: 현재 행의 U.id를 참조하여 해당 사용자의 총 주문 횟수 계산 (SELECT COUNT(O.id) FROM Orders O WHERE O.user_id = U.id) AS order_count -- 계산된 주문 횟수를 order_count 컬럼으로 표시FROM Users U;
서브쿼리의 결과를 하나의 임시 테이블(인라인 뷰)처럼 취급하여 메인 쿼리에서 사용할 수 있게 합니다. 반드시 별칭(alias)을 지정해야 합니다.
인라인 뷰는 서브쿼리의 결과를 테이블처럼 사용하기 때문에, 메인 쿼리에서 조인, 필터링 등 다양한 작업을 수행할 수 있습니다.
예시
각 사용자의 평균 주문 금액보다 높은 금액의 주문들만 조회
-- 메인 쿼리: 주문 정보와 사용자 이름 조회SELECT U.name, -- 사용자 이름 UserAvgOrders.product, -- 인라인 뷰에서 가져온 상품명 UserAvgOrders.amount -- 인라인 뷰에서 가져온 주문 금액FROM Users U-- 인라인 뷰 (UserAvgOrders): 사용자별 평균 주문 금액보다 큰 주문들만 필터링JOIN ( SELECT O.user_id, -- 사용자 ID O.product, -- 상품명 O.amount, -- 주문 금액 -- 각 사용자별(PARTITION BY O.user_id) 평균 주문 금액을 계산하는 윈도우 함수 AVG(O.amount) OVER (PARTITION BY O.user_id) AS avg_user_amount FROM Orders O WHERE O.user_id IS NOT NULL) UserAvgOrders ON U.id = UserAvgOrders.user_id -- Users 테이블과 인라인 뷰(UserAvgOrders)를 사용자 ID로 조인WHERE UserAvgOrders.amount > UserAvgOrders.avg_user_amount; -- 조건: 주문 금액이 해당 사용자 평균 주문 금액보다 큰 경우
name
product
amount
홍길동
Strawberry
8000
햄랑이
Melon
6000
각 사용자의 평균 주문액을 초과하는 주문들만 나옴. 예시 데이터에 따라 결과가 달라질 수 있음
조건을 비교하기 위해 사용되며, 반환 결과에 따라 단일 행/다중 행 연산자와 함께 사용됩니다.
단일 행 서브쿼리
= , > , < 등과 사용.
다중 행 서브쿼리
IN, NOT IN, ANY, ALL, EXISTS 등과 사용.
ANY: 서브쿼리 결과 중 하나라도 조건을 만족하면 TRUE. (예: < ANY는 최솟값보다 작으면 TRUE)
ALL: 서브쿼리 결과 모두 조건을 만족해야 TRUE. (예: > ALL은 최댓값보다 크면 TRUE)
NOT IN 사용 시 주의점
서브쿼리 결과에 NULL이 포함되어 있고, 메인 쿼리의 비교 컬럼도 NULL이 가능하면 예상치 못한 결과(주로 아무것도 선택되지 않음)가 나올 수 있습니다.
서브쿼리 내에서 WHERE column IS NOT NULL 조건을 추가하거나 COALESCE 등을 사용하는 것이 안전합니다.
예시 1 (단일 행 서브쿼리)
가장 나이가 많은 사용자의 이름과 나이 조회
SELECT name, -- 사용자 이름 age -- 사용자 나이FROM UsersWHERE age = (SELECT MAX(age) FROM Users);-- 조건: 나이가 (Users 테이블의 최대 나이)와 같은 경우
name
age
제임스
30
예시 2 (다중 행 서브쿼리 - IN 사용)
'Apple' 또는 'Banana'를 주문한 모든 사용자의 이름 조회 (중복 제거)
SELECT DISTINCT -- 중복된 사용자 이름 제거 U.name -- 사용자 이름FROM Users U-- 조건: 사용자 ID가 (아래 서브쿼리 결과 목록: 'Apple' 또는 'Banana'를 주문한 사용자 ID들)에 포함되는 경우WHERE U.id IN ( SELECT O.user_id FROM Orders O WHERE O.product IN ('Apple', 'Banana') AND O.user_id IS NOT NULL);
name
홍길동
이동훈
제임스
예시 3 (다중 행 서브쿼리 - ANY 사용)
'홍길동'이 주문한 어떤 상품의 주문 금액보다 큰 주문 금액을 가진 다른 주문들 조회
SELECT O.id AS order_id, O.product, O.amountFROM Orders OWHERE O.amount > ANY ( -- 조건: 주문 금액이 (홍길동의 주문 금액들 중 '어떤 것'보다라도) 큰 경우 SELECT amount FROM Orders WHERE user_id = (SELECT id FROM Users WHERE name = '홍길동'))AND O.user_id != (SELECT id FROM Users WHERE name = '홍길동'); -- 홍길동 본인의 주문은 제외
(홍길동의 주문 금액들 중 최솟값보다 큰 다른 주문들이 조회됨)
예시 4 (상관 서브쿼리 - EXISTS 사용)
한 번이라도 주문한 적이 있는 사용자 목록 조회
SELECT U.name, -- 사용자 이름 U.email -- 사용자 이메일FROM Users U-- 조건: 다음 서브쿼리의 결과가 하나라도 존재하면 TRUE (상관 서브쿼리)WHERE EXISTS ( SELECT 1 -- 실제 값을 가져올 필요 없이 존재 여부만 체크 FROM Orders O WHERE O.user_id = U.id -- 외부 쿼리 Users 테이블의 현재 행 U.id를 참조);
GROUP BY 절과 함께 사용되어 지정된 그룹핑 컬럼들의 계층 구조에 따라 소계와 총계를 생성합니다.
MySQL
GROUP BY ... WITH ROLLUP. 결과에서 집계된 행의 그룹핑 컬럼 값은 NULL로 표시됩니다.
Oracle
GROUP BY ROLLUP(col1, col2, ...). GROUPING(col) 함수를 사용하여 해당 컬럼이 집계로 인해 NULL이 된 것인지 (결과 1), 아니면 원래 값이 NULL인지 (결과 0) 구분할 수 있습니다.
예시
사용자(Users.name)별, 상품(Orders.product)별 주문 총액 및 각 사용자별 총액, 전체 총액 보기
MySQL (WITH ROLLUP 사용)
SELECT -- 이름이 NULL이면 '전체 총계', 상품명이 NULL이고 이름이 있으면 '사용자별 소계'로 표시 IF(U.name IS NULL, '전체 총계', U.name) AS user_name, IF(O.product IS NULL, IF(U.name IS NULL, '', '사용자별 소계'), O.product) AS product_name, SUM(O.amount) AS total_sales -- 주문 총액FROM Orders OJOIN Users U ON O.user_id = U.idGROUP BY U.name, O.product WITH ROLLUP -- 이름과 상품별로 그룹화하고 ROLLUP 적용ORDER BY U.name, O.product; -- 정렬 (NULL은 기본적으로 처음에 올 수 있음)
Oracle (ROLLUP 및 GROUPING 함수 사용)
SELECT -- GROUPING(U.name)이 1이면 U.name이 ROLLUP에 의해 집계된 것이므로 '전체 총계' CASE WHEN GROUPING(U.name) = 1 THEN '전체 총계' ELSE U.name END AS user_name, CASE -- GROUPING(O.product)이 1이면 O.product가 ROLLUP에 의해 집계된 것 WHEN GROUPING(O.product) = 1 THEN -- GROUPING(U.name)도 1이면 (즉, 전체 총계 행이면) 빈 문자열, 아니면 (사용자별 소계 행이면) '사용자별 소계' CASE WHEN GROUPING(U.name) = 1 THEN '' ELSE '사용자별 소계' END ELSE O.product -- 집계된 것이 아니면 상품명 그대로 END AS product_name, SUM(O.amount) AS total_sales -- 주문 총액FROM Orders OJOIN Users U ON O.user_id = U.idGROUP BY ROLLUP(U.name, O.product) -- 이름과 상품별로 그룹화하고 ROLLUP 적용ORDER BY U.name NULLS LAST, O.product NULLS LAST; -- NULLS LAST로 총계/소계 행들을 마지막에 정렬
user_name
product_name
total_sales
이동훈
Banana
2000
이동훈
Grape
3000
이동훈
NULL
5000
...
(결과는 여러 행으로 나타나며, user_name 또는 product_name이 '전체 총계' 또는 '사용자별 소계'인 행들이 소계/총계를 나타냅니다.)
GROUP_CONCAT(DISTINCT expression ORDER BY expression ASC/DESC SEPARATOR str_val). 기본 구분자는 콤마(,)입니다. 반환되는 문자열의 최대 길이는 group_concat_max_len 시스템 변수에 의해 제한됩니다.
Oracle
LISTAGG(measure_expr, delimiter) WITHIN GROUP (ORDER BY order_by_clause). 구분자를 반드시 지정해야 합니다. 반환 문자열 최대 길이는 4000 바이트 (VARCHAR2) 또는 32767 바이트 (CLOB - 추가 처리 필요) 입니다.
예시
각 사용자(Users.name)가 주문한 상품 목록(Orders.product)을 콤마로 구분하여 나열하기 (중복 없이, 상품명 오름차순)
MySQL
SELECT U.name, -- 사용자 이름 -- 주문한 상품들을 상품명 오름차순으로 정렬하여 ', '로 구분하여 하나의 문자열로 만듦 (중복 상품은 한 번만) GROUP_CONCAT(DISTINCT O.product ORDER BY O.product ASC SEPARATOR ', ') AS products_orderedFROM Users UJOIN Orders O ON U.id = O.user_id -- Users와 Orders 조인GROUP BY U.name; -- 사용자 이름으로 그룹화
Oracle
SELECT U.name, -- 사용자 이름 -- 주문한 상품들을 상품명 오름차순으로 정렬하여 ', '로 구분하여 하나의 문자열로 만듦 (중복 상품은 한 번만) LISTAGG(DISTINCT O.product, ', ') WITHIN GROUP (ORDER BY O.product ASC) AS products_orderedFROM Users UJOIN Orders O ON U.id = O.user_id -- Users와 Orders 조인GROUP BY U.name; -- 사용자 이름으로 그룹화
이번 3편에서는 CTE와 서브쿼리를 중심으로 복잡한 쿼리를 구조화하고 데이터를 효과적으로 가져오는 방법을 살펴보았습니다.
또한, 고급 집계 함수의 일부인 ROLLUP과 문자열 집계 함수도 함께 다루었습니다.
다음 편에서는 오늘 다루지 못한 나머지 고급 집계 함수(CUBE, GROUPING SETS), 강력한 문자열 처리를 위한 정규표현식, 윈도우 함수의 더 깊이 있는 활용법(분석 함수, 프레임 절 심화), 그리고 데이터를 행에서 열로, 또는 열에서 행으로 변환하는 피벗팅과 언피벗팅 기법에 대해 자세히 알아보겠습니다.