Programmers SQL Level 3에서 반복적으로 등장하는 복합 JOIN, 상관 서브쿼리, EXISTS, UNION, 조건부 집계, GROUP_CONCAT, CTE 기본, JOIN USING 등을 DS·AIE 트랙 우선순위로 정리한다.
Code Test
저자
Kwangmin Kim
공개
2026년 04월 15일
1 개요
SQL Level 3은 단순 조회를 넘어 쿼리 안에서 논리적 추론을 수행하는 능력을 평가한다. 상관 서브쿼리, EXISTS, 자기 참조 JOIN 등 고급 패턴을 이해하면 복잡한 비즈니스 문제를 SQL 한 쿼리로 해결할 수 있다.
2 DS vs AIE 트랙 우선순위
구문
DS
AIE
설명
상관 서브쿼리
★★★
★★☆
외부 쿼리 참조 서브쿼리
EXISTS / NOT EXISTS
★★★
★★☆
존재 여부 확인
UNION / UNION ALL
★★★
★★☆
결과 집합 합치기
다중 조건 집계
★★★
★★☆
CASE WHEN + GROUP BY 조합
Self JOIN
★★★
★★☆
같은 테이블 자기 참조
날짜 고급 함수
★★★
★☆☆
LAST_DAY, EXTRACT
NULLIF
★★★
★☆☆
특정 값을 NULL로 변환
GROUP_CONCAT
★★★
★★☆
그룹 내 값 문자열 결합
CTE (WITH, 비재귀)
★★★
★★★
복잡 쿼리 분해
다중 컬럼 GROUP BY
★★★
★★☆
조합 집계
JOIN USING
★★☆
★★☆
동일 컬럼명 JOIN 단축
ORDER BY FIELD / CASE
★★☆
★☆☆
논리적 정렬
TIMESTAMPDIFF
★★★
★★☆
두 날짜 차이 (원하는 단위)
HAVING + 서브쿼리
★★★
★★☆
전체 평균 비교 등
3 상관 서브쿼리 (Correlated Subquery)
-- 외부 쿼리의 각 행에 대해 서브쿼리가 반복 실행된다-- 각 직원의 급여가 자신이 속한 부서 평균보다 높은가?SELECT name, dept, salaryFROM employees e1WHERE salary > (SELECTAVG(salary)FROM employees e2WHERE e2.dept = e1.dept -- 외부 쿼리의 e1.dept 참조);-- 결과: 부서 평균 이상의 직원만 반환-- 주의: 외부 행 수만큼 서브쿼리 반복 실행 → 대용량에서 느릴 수 있음-- 가장 최근 주문 날짜의 주문 조회 (상관 서브쿼리)SELECT*FROM orders o1WHERE order_date = (SELECTMAX(order_date)FROM orders o2WHERE o2.customer_id = o1.customer_id -- 고객별 최근 주문);-- 각 고객의 가장 최근 주문 1건씩 반환
4 EXISTS / NOT EXISTS
-- EXISTS: 서브쿼리가 결과를 반환하면 TRUE-- IN과 유사하지만 서브쿼리 결과가 많을 때 EXISTS가 더 빠르다-- 입양된 적 있는 동물 조회SELECT a.NAMEFROM ANIMAL_INS aWHEREEXISTS (SELECT1FROM ANIMAL_OUTS oWHERE o.ANIMAL_ID = a.ANIMAL_ID);-- 결과: ANIMAL_OUTS에 매칭 행이 있는 동물만 반환-- NOT EXISTS: 서브쿼리가 결과를 반환하지 않으면 TRUE-- 한 번도 입양되지 않은 동물 조회SELECT a.NAMEFROM ANIMAL_INS aWHERENOTEXISTS (SELECT1FROM ANIMAL_OUTS oWHERE o.ANIMAL_ID = a.ANIMAL_ID);-- 결과: ANIMAL_OUTS에 매칭 행이 없는 동물 (LEFT JOIN WHERE IS NULL과 동일)-- IN vs EXISTS 성능 차이-- 서브쿼리 결과가 작으면: IN이 직관적-- 서브쿼리 결과가 크면: EXISTS가 빠름 (첫 매칭 발견 시 바로 중단)
5 UNION / UNION ALL
-- UNION: 두 결과 집합을 합치고 중복 제거 (느림)-- UNION ALL: 두 결과 집합을 합치고 중복 유지 (빠름)-- 개와 고양이를 분리 조회 후 합치기SELECT'개'AStype, NAME FROM ANIMAL_INS WHERE ANIMAL_TYPE ='Dog'UNIONALLSELECT'고양이', NAME FROM ANIMAL_INS WHERE ANIMAL_TYPE ='Cat'ORDERBYtype, NAME;-- 결과: 개 목록 + 고양이 목록 (중복 유지)-- 두 테이블에서 각각 조건에 맞는 행 합치기SELECT ANIMAL_ID, 'INS_ONLY'AS statusFROM ANIMAL_INSWHERE ANIMAL_ID NOTIN (SELECT ANIMAL_ID FROM ANIMAL_OUTS)UNIONALLSELECT ANIMAL_ID, 'OUTS_ONLY'FROM ANIMAL_OUTSWHERE ANIMAL_ID NOTIN (SELECT ANIMAL_ID FROM ANIMAL_INS);
6 Self JOIN
-- 같은 테이블을 두 번 참조하는 JOIN-- 예: employees(id, name, manager_id)에서 직원-상사 쌍 조회SELECT e.name AS 직원, m.name AS 상사FROM employees eJOIN employees m ON e.manager_id = m.id;-- 결과:-- 직원 | 상사-- ------|------- Lee | Kim (Kim이 Lee의 상사)-- Park | Kim-- Choi | Lee-- NULL 처리: manager_id가 NULL인 최상위 관리자 포함SELECT e.name AS 직원, COALESCE(m.name, '최상위') AS 상사FROM employees eLEFTJOIN employees m ON e.manager_id = m.id;
7 조건부 집계 (Conditional Aggregation)
-- CASE WHEN + 집계함수 조합으로 피벗 없이 통계 생성-- 예: 월별 완료/미완료 주문 수를 한 행으로SELECTMONTH(order_date) AS 월,COUNT(*) AS 전체,SUM(CASEWHEN status ='complete'THEN1ELSE0END) AS 완료,SUM(CASEWHEN status ='pending'THEN1ELSE0END) AS 대기중,AVG(CASEWHEN status ='complete'THEN amount END) AS 완료_평균금액FROM ordersGROUPBY 월ORDERBY 월;-- 결과: 월별로 완료/대기 건수와 완료 주문의 평균 금액이 한 행에-- 부서별 성별 인원 수 (피벗)SELECT dept,SUM(CASEWHEN gender ='M'THEN1ELSE0END) AS 남성,SUM(CASEWHEN gender ='F'THEN1ELSE0END) AS 여성FROM employeesGROUPBY dept;
8 고급 날짜 함수
-- LAST_DAY: 해당 월의 마지막 날SELECTLAST_DAY('2024-02-01'); -- → '2024-02-29' (2024년 윤년)SELECTLAST_DAY('2023-02-01'); -- → '2023-02-28'-- EXTRACT: 날짜 구성요소 추출SELECTEXTRACT(YEARFROM'2024-03-15'); -- → 2024SELECTEXTRACT(QUARTER FROM'2024-03-15'); -- → 1 (1분기)SELECTEXTRACT(WEEK FROM'2024-03-15'); -- → 11 (11번째 주)-- DATE_FORMAT 고급SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); -- → '2024-03-15 09:30:00'SELECT DATE_FORMAT(NOW(), '%W, %M %d %Y'); -- → 'Friday, March 15 2024'-- TIMESTAMPDIFF: 두 날짜 차이를 원하는 단위로SELECT TIMESTAMPDIFF(MONTH, '2020-01-01', '2024-03-15'); -- → 50 (개월 수)SELECT TIMESTAMPDIFF(YEAR, birth_date, NOW()) AS age FROM users;-- 각 사용자의 만 나이 계산
9 NULLIF
-- NULLIF(a, b): a = b이면 NULL 반환, 아니면 a 반환-- 0으로 나누기 오류 방지에 자주 사용SELECT name, total_sales /NULLIF(visit_count, 0) AS avg_per_visit-- visit_count가 0이면 NULLIF가 NULL 반환 → 나누기 오류 방지FROM sales_reps;-- 특정 값을 NULL로 대체 (이후 COALESCE로 처리)SELECTCOALESCE(NULLIF(status, 'N/A'), '미입력') AS clean_status FROMdata;-- status가 'N/A'면 NULL로 변환 → COALESCE로 '미입력'으로 처리
10 GROUP_CONCAT — 그룹 내 값 결합
-- 그룹별 값을 쉼표 등으로 이어붙여 한 행으로 (MySQL)SELECT dept, GROUP_CONCAT(name ORDERBY name SEPARATOR ', ') AS membersFROM employeesGROUPBY dept;-- 결과:-- dept | members-- ------|----------------------- Sales | Kim, Lee, Park-- IT | Choi, Jung-- DISTINCT + 정렬 + 구분자 조합SELECTcategory, GROUP_CONCAT(DISTINCT tag ORDERBY tag ASC SEPARATOR '|') AS tagsFROM articlesGROUPBYcategory;-- 최대 길이 주의: group_concat_max_len 기본 1024자 (긴 경우 SET SESSION으로 확장)
PostgreSQL은 STRING_AGG(col, ', '), Oracle은 LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col)
Programmers “우유와 요거트가 담긴 장바구니”, “조건에 맞는 사용자 정보 조회하기” 등에서 활용
11 CTE 기본 (Level 3 도입)
-- 복잡한 서브쿼리를 이름 붙여 분리 (비재귀 CTE)WITH high_earners AS (SELECT dept, AVG(salary) AS avg_salFROM employeesWHERE salary >5000GROUPBY dept)SELECT dept, avg_salFROM high_earnersWHERE avg_sal >=7000;-- 다중 CTE: 순차 참조 가능WITHmonthly AS (SELECT DATE_FORMAT(order_date, '%Y-%m') AS ym, SUM(amount) AS totalFROM orders GROUPBY ym),above_avg AS (SELECT ym, total FROM monthlyWHERE total > (SELECTAVG(total) FROM monthly))SELECT*FROM above_avg ORDERBY ym;
같은 서브쿼리를 여러 번 참조해야 할 때 중복 제거
재귀 CTE는 Level 5에서 다룸
12 다중 컬럼 GROUP BY
-- 여러 기준을 조합한 집계SELECTcategory, year_month, COUNT(*), SUM(amount)FROM ordersGROUPBYcategory, DATE_FORMAT(order_date, '%Y-%m')ORDERBYcategory, year_month;-- 주의: SELECT의 모든 비집계 컬럼은 GROUP BY에 포함되어야 한다 (표준 SQL)-- MySQL ONLY_FULL_GROUP_BY 모드에서도 동일
13 JOIN USING / NATURAL JOIN
-- USING: 양쪽 테이블의 동일 컬럼명 JOIN 단축SELECT i.NAME, o.DATETIME_OUTFROM ANIMAL_INS i JOIN ANIMAL_OUTS o USING (ANIMAL_ID);-- = ON i.ANIMAL_ID = o.ANIMAL_ID 와 동일-- SELECT에서 ANIMAL_ID는 테이블 별칭 없이 참조해야 함-- NATURAL JOIN: 모든 동일명 컬럼으로 자동 JOIN (권장 안 함 — 의도 불명)-- 예상치 못한 컬럼 매칭이 발생할 수 있어 명시적 ON 또는 USING 권장
14 TIMESTAMPDIFF / DATE_ADD 심화
-- TIMESTAMPDIFF(단위, 시작, 끝): 단위별 차이SELECT TIMESTAMPDIFF(YEAR, birth_date, NOW()) AS age FROM users;SELECT TIMESTAMPDIFF(MONTH, start_date, end_date) AS months FROM contracts;SELECT TIMESTAMPDIFF(HOUR, check_in, check_out) AS hours FROM bookings;-- 단위: SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, QUARTER, YEAR-- DATE_ADD / DATE_SUB 단위 종류SELECT DATE_ADD('2024-01-15', INTERVAL3MONTH); -- → '2024-04-15'SELECT DATE_SUB(NOW(), INTERVAL1 WEEK); -- 일주일 전-- 특정 기간 내 필터링 (일관된 패턴)WHERE order_date >= DATE_SUB(NOW(), INTERVAL30DAY)WHERE order_date BETWEEN'2024-01-01'ANDLAST_DAY('2024-01-01')
15 HAVING + 서브쿼리
-- 전체 평균보다 평균 급여가 높은 부서SELECT dept, AVG(salary) AS avg_salFROM employeesGROUPBY deptHAVINGAVG(salary) > (SELECTAVG(salary) FROM employees);-- 결과: 부서 평균이 전사 평균 초과인 부서만-- 최다 주문 고객SELECT customer_id, COUNT(*) AS cntFROM ordersGROUPBY customer_idHAVINGCOUNT(*) = (SELECTMAX(cnt) FROM (SELECTCOUNT(*) AS cnt FROM orders GROUPBY customer_id ) t);
16 ORDER BY FIELD / CASE (논리 정렬)
-- 사용자 정의 순서로 정렬SELECT name, gradeFROM studentsORDERBY FIELD(grade, 'A', 'B', 'C', 'D', 'F');-- = ORDER BY CASE grade WHEN 'A' THEN 1 ... END (표준 SQL 호환)-- NULL을 맨 뒤로ORDERBY col ISNULL, col ASC; -- MySQL 관용-- 또는 ORDER BY ISNULL(col), col ASC;