Programmers SQL Level 2에서 반복적으로 등장하는 JOIN, GROUP BY, HAVING, CASE WHEN, 서브쿼리, EXISTS, 정렬 보조 함수(FIELD), 문자열·숫자·NULL 처리 내장 함수를 DS·AIE 트랙 우선순위로 정리한다.
Code Test
저자
Kwangmin Kim
공개
2026년 04월 15일
1 개요
SQL Level 2는 두 개 이상의 테이블을 조합하거나 그룹 기반 조건 필터링을 수행하는 능력을 평가한다. JOIN의 종류와 동작 방식, HAVING과 WHERE의 차이를 명확히 이해하는 것이 핵심이다.
2 DS vs AIE 트랙 우선순위
구문
DS
AIE
설명
INNER JOIN
★★★
★★★
교집합 행 결합
LEFT JOIN
★★★
★★★
왼쪽 테이블 기준 결합
GROUP BY + HAVING
★★★
★★☆
그룹 필터
CASE WHEN
★★★
★★☆
조건부 열
서브쿼리 (WHERE 절)
★★★
★★☆
중첩 쿼리
DATEDIFF, DATE_ADD
★★★
★☆☆
날짜 계산
COALESCE
★★★
★★☆
NULL 대체 (다중 값)
IFNULL / NULLIF
★★★
★★☆
NULL 치환 / 동일값 NULL화
EXISTS / NOT EXISTS
★★☆
★★☆
상관 서브쿼리 존재 검사
FIELD
★★☆
★☆☆
문자열 범주 논리 정렬 (MySQL)
GREATEST / LEAST
★★☆
★★☆
다중 값 중 최대·최소
CONCAT / CONCAT_WS
★★★
★★☆
문자열 결합
SUBSTRING / LEFT / RIGHT
★★★
★★☆
부분 문자열
LENGTH / CHAR_LENGTH
★★☆
★☆☆
문자열 길이
REPLACE / LOWER / UPPER
★★☆
★☆☆
문자열 치환·대소문자
ROUND / CEIL / FLOOR / TRUNCATE
★★★
★★☆
반올림·올림·내림
MOD / ABS / POWER
★★☆
★★☆
나머지·절댓값·거듭제곱
YEAR / MONTH / DAY / DAYOFWEEK
★★★
★★☆
날짜 부분 추출
DATE_FORMAT / STR_TO_DATE
★★★
★★☆
날짜 포맷 변환
UNION / UNION ALL
★★☆
★★☆
쿼리 결과 수직 결합
LIMIT OFFSET
★★★
★★☆
페이지네이션
3 JOIN
-- 입력:-- ANIMAL_INS: (ANIMAL_ID, NAME, DATETIME_IN)-- A001, Toto, 2022-01-01-- A002, Mimi, 2022-02-01-- A003, Coco, 2022-03-01-- ANIMAL_OUTS: (ANIMAL_ID, NAME, DATETIME_OUT)-- A001, Toto, 2022-01-10-- A003, Coco, 2022-03-15-- INNER JOIN: 양쪽 모두 존재하는 행만SELECT i.ANIMAL_ID, i.NAME, i.DATETIME_IN, o.DATETIME_OUTFROM ANIMAL_INS iINNERJOIN ANIMAL_OUTS o ON i.ANIMAL_ID = o.ANIMAL_ID;-- 결과: A001(Toto), A003(Coco) 2행만 — A002(Mimi)는 OUTS에 없으므로 제외-- LEFT JOIN: 왼쪽 테이블 전체 + 오른쪽은 매칭되면 값, 없으면 NULLSELECT i.ANIMAL_ID, i.NAME, o.DATETIME_OUTFROM ANIMAL_INS iLEFTJOIN ANIMAL_OUTS o ON i.ANIMAL_ID = o.ANIMAL_ID;-- 결과:-- ANIMAL_ID | NAME | DATETIME_OUT-- ----------|------|--------------- A001 | Toto | 2022-01-10-- A002 | Mimi | NULL ← OUTS에 없음-- A003 | Coco | 2022-03-15-- LEFT JOIN으로 "아직 입양 안 된 동물" 찾기 (NULL 활용)SELECT i.ANIMAL_ID, i.NAMEFROM ANIMAL_INS iLEFTJOIN ANIMAL_OUTS o ON i.ANIMAL_ID = o.ANIMAL_IDWHERE o.ANIMAL_ID ISNULL;-- 결과: A002(Mimi) — OUTS에 없는 행-- 다중 JOINSELECT a.name, b.dept, c.salaryFROM employees aJOIN departments b ON a.dept_id = b.idJOIN salaries c ON a.id= c.emp_id;
4 GROUP BY + HAVING
-- GROUP BY: 그룹별 집계-- HAVING: 집계 결과 필터 (WHERE는 집계 전, HAVING은 집계 후)-- 입력: orders (order_id, customer_id, amount)-- 1,C001,3000 / 2,C001,2000 / 3,C002,5000 / 4,C002,1000 / 5,C003,4000SELECT customer_id, COUNT(*) AS order_cnt, SUM(amount) AS totalFROM ordersGROUPBY customer_id;-- 결과:-- customer_id | order_cnt | total-- ------------|-----------|-------- C001 | 2 | 5000-- C002 | 2 | 6000-- C003 | 1 | 4000-- HAVING: 집계 후 조건 (2회 이상 주문한 고객)SELECT customer_id, COUNT(*) AS cntFROM ordersGROUPBY customer_idHAVINGCOUNT(*) >=2;-- 결과: C001(2), C002(2) — C003은 1회라 제외-- WHERE vs HAVINGSELECT customer_id, COUNT(*) AS cntFROM ordersWHERE amount >=2000-- 집계 전 필터: 2000원 이상 주문만 대상GROUPBY customer_idHAVINGCOUNT(*) >=2; -- 집계 후 필터: 2회 이상인 고객만
5 CASE WHEN
-- CASE WHEN: 조건부 값 반환 (if-else와 동일)SELECT NAME,CASEWHEN SEX_UPON_INTAKE LIKE'Intact%'THEN'중성화 전'WHEN SEX_UPON_INTAKE LIKE'Spayed%'THEN'중성화 완료'WHEN SEX_UPON_INTAKE LIKE'Neutered%'THEN'중성화 완료'ELSE'알 수 없음'ENDAS 중성화_여부FROM ANIMAL_INS;-- 결과: 각 동물의 중성화 상태를 한국어로 변환-- CASE WHEN으로 그룹 집계SELECTCASEWHEN salary >=5000THEN'고연봉'WHEN salary >=3000THEN'중연봉'ELSE'저연봉'ENDAS 연봉_등급,COUNT(*) AS 직원수FROM employeesGROUPBY 연봉_등급;-- 결과: 연봉 등급별 직원 수
6 서브쿼리
-- WHERE 절 서브쿼리: 집계 결과를 조건으로 활용SELECT NAME, salaryFROM employeesWHERE salary > (SELECTAVG(salary) FROM employees);-- 결과: 평균 급여 이상인 직원 (AVG가 먼저 계산되어 비교값으로 사용됨)-- IN + 서브쿼리SELECT NAME FROM ANIMAL_INSWHERE ANIMAL_ID IN (SELECT ANIMAL_ID FROM ANIMAL_OUTS);-- 결과: 입양된 동물의 이름 (INNER JOIN과 동일 결과)-- 스칼라 서브쿼리: SELECT 절에서 단일 값 반환SELECT NAME, salary, (SELECTAVG(salary) FROM employees) AS avg_salary, salary - (SELECTAVG(salary) FROM employees) AS diffFROM employees;-- 결과: 각 직원의 급여와 평균 대비 차이
7 EXISTS / NOT EXISTS — 상관 서브쿼리 존재 검사
-- 입력:-- customers: (customer_id, name)-- C001 Kim / C002 Lee / C003 Park-- orders: (order_id, customer_id, amount)-- 1 C001 3000 / 2 C001 2000 / 3 C002 5000-- EXISTS: 주문이 최소 1건 있는 고객SELECT customer_id, nameFROM customers cWHEREEXISTS (SELECT1FROM orders oWHERE o.customer_id = c.customer_id -- 상관조건: 바깥 행과 연결);-- 결과: C001(Kim), C002(Lee)-- NOT EXISTS: 주문이 한 번도 없는 고객SELECT customer_id, nameFROM customers cWHERENOTEXISTS (SELECT1FROM orders oWHERE o.customer_id = c.customer_id);-- 결과: C003(Park)
EXISTS는 서브쿼리가 한 행이라도 반환하면 TRUE. SELECT 리스트는 SELECT 1이 관용이며 실제 컬럼값은 평가하지 않는다
NOT EXISTS는 NOT IN과 달리 NULL-safe. 서브쿼리 결과에 NULL이 섞여도 올바르게 동작한다 (NOT IN은 NULL이 끼면 전체 UNKNOWN)
JOIN·IN·EXISTS의 선택 기준: (1) 존재 여부만 필요 → EXISTS, (2) 반환 컬럼도 필요 → JOIN, (3) 작은 상수 집합 비교 → IN
8 FIELD — 문자열 범주 논리 정렬 (MySQL)
-- 알파벳 순이 아닌 논리 순서로 정렬SELECTcategory, price_range, product_countFROM category_summaryORDERBY FIELD(price_range, 'LOW', 'MID', 'HIGH');-- FIELD(col, v1, v2, v3) → v1이면 1, v2면 2, v3이면 3 반환-- 결과: LOW → MID → HIGH 순 (알파벳 순 HIGH/LOW/MID 아님)-- 등급·우선순위 정렬ORDERBY FIELD(priority, 'URGENT', 'HIGH', 'NORMAL', 'LOW');
FIELD는 MySQL 전용. PostgreSQL·Oracle에서는 ORDER BY CASE col WHEN 'LOW' THEN 1 WHEN 'MID' THEN 2 ELSE 3 END로 대체
존재하지 않는 값은 0을 반환하여 가장 앞에 온다. 누락 방어가 필요하면 CASE 방식 권장
9 IFNULL / NULLIF — NULL 양방향 처리
-- IFNULL: NULL을 지정 값으로 치환 (COALESCE의 2인자 버전)SELECT IFNULL(nickname, '익명') FROM users;-- nickname이 NULL이면 '익명', 아니면 nickname 그대로-- NULLIF: 두 값이 같으면 NULL 반환 (0으로 나누기 방어에 활용)SELECT total /NULLIF(quantity, 0) AS unit_price FROM orders;-- quantity가 0이면 NULL / 0 → NULL (에러 대신 NULL 반환)-- quantity가 0이 아니면 정상 나눗셈-- COALESCE vs IFNULL 비교SELECTCOALESCE(a, b, c, '기본') -- 다중 값 (표준 SQL) , IFNULL(a, '기본'); -- 2인자만 (MySQL 방언)
IFNULL 2인자, COALESCE 다중 인자 — 의미는 동일하나 COALESCE가 표준 SQL
NULLIF(a, b) = CASE WHEN a = b THEN NULL ELSE a END — 주로 0으로 나누기 방어에 활용
10 GREATEST / LEAST — 다중 값 최대·최소
-- 여러 컬럼 중 최대·최소 (같은 행에서 비교)SELECT order_id,GREATEST(web_price, app_price, store_price) AS max_price,LEAST(web_price, app_price, store_price) AS min_priceFROM multi_channel_price;-- 날짜 비교: 두 날짜 중 더 최근SELECTGREATEST(last_login, last_order) AS last_active FROM users;
GREATEST/LEAST는 같은 행의 여러 컬럼을 비교 (수평 비교). MAX/MIN은 여러 행의 한 컬럼을 집계 (수직 비교)
인자 중 하나라도 NULL이면 결과 NULL. NULL 방어는 COALESCE로 감싼다
11 문자열 함수
-- CONCAT: 문자열 결합SELECTCONCAT(first_name, ' ', last_name) AS full_name FROM users;-- 인자 중 하나라도 NULL이면 결과 NULL-- CONCAT_WS: 구분자 지정 결합 (NULL은 자동 무시)SELECT CONCAT_WS('-', area, city, street) AS address FROM locations;-- area가 NULL이어도 나머지만 '-'로 연결-- SUBSTRING / LEFT / RIGHT: 부분 문자열SELECT SUBSTRING('2026-04-15', 1, 4); -- → '2026' (1-index, 4글자)SELECTLEFT('abcdef', 3); -- → 'abc'SELECTRIGHT('abcdef', 3); -- → 'def'-- LENGTH vs CHAR_LENGTH: 바이트 수 vs 글자 수SELECTLENGTH('한글'), CHAR_LENGTH('한글');-- LENGTH=6 (UTF-8 3바이트×2), CHAR_LENGTH=2-- 한글 포함 시 CHAR_LENGTH가 의도에 부합-- REPLACE: 치환SELECTREPLACE('010-1234-5678', '-', ''); -- → '01012345678'-- LOWER / UPPER: 대소문자 변환SELECTLOWER(email) FROM users;-- LIKE 와일드카드: %=0개이상, _=정확히1개WHERE name LIKE'K%'-- K로 시작WHERE name LIKE'%son'-- son으로 끝WHERE name LIKE'_im'-- 첫글자+im (3글자)
-- DATEDIFF: 날짜 차이 (일 수)SELECT DATEDIFF('2022-03-15', '2022-01-01'); -- → 73 (일)SELECT NAME, DATEDIFF(DATETIME_OUT, DATETIME_IN) AS days_stayedFROM ANIMAL_INSJOIN ANIMAL_OUTS USING (ANIMAL_ID);-- 결과: 각 동물의 보호 기간 (일)-- DATE_ADD / DATE_SUB: 날짜 더하기/빼기SELECT DATE_ADD('2022-01-01', INTERVAL30DAY); -- → '2022-01-31'SELECT DATE_SUB('2022-03-15', INTERVAL1MONTH); -- → '2022-02-15'-- NOW(): 현재 시각SELECT*FROM orders WHERE DATETIME >= DATE_SUB(NOW(), INTERVAL7DAY);-- 최근 7일 이내 주문 조회-- YEAR / MONTH / DAY / DAYOFWEEK / HOUR: 날짜 부분 추출SELECTYEAR('2026-04-15'), MONTH('2026-04-15'), DAY('2026-04-15');-- → 2026, 4, 15SELECT DAYOFWEEK('2026-04-15'); -- → 4 (일=1, 월=2, ..., 토=7)-- 월별·연도별 집계 패턴 (매우 빈출)SELECTYEAR(order_date) AS yr, MONTH(order_date) AS mo, COUNT(*) AS cntFROM ordersGROUPBYYEAR(order_date), MONTH(order_date)ORDERBY yr, mo;-- DATE_FORMAT: 날짜 → 문자열 (MySQL)SELECT DATE_FORMAT(order_date, '%Y-%m-%d') FROM orders;-- %Y=4자리연도, %m=2자리월, %d=2자리일, %H=24시간, %i=분, %s=초-- Programmers 출력 형식 맞추기용으로 가장 많이 쓰임-- STR_TO_DATE: 문자열 → 날짜SELECT STR_TO_DATE('15/04/2026', '%d/%m/%Y'); -- → 2026-04-15-- CURDATE / CURTIME: 오늘 날짜 / 현재 시각만SELECT CURDATE(); -- → '2026-04-15'
14 UNION / UNION ALL — 쿼리 결과 수직 결합
-- UNION: 중복 제거하고 결합 (DISTINCT 자동 적용)SELECT name FROM vip_customersUNIONSELECT name FROM regular_customers;-- UNION ALL: 중복 허용 결합 (성능 우수)SELECT'web'AS channel, COUNT(*) FROM web_ordersUNIONALLSELECT'app'AS channel, COUNT(*) FROM app_ordersUNIONALLSELECT'store'AS channel, COUNT(*) FROM store_orders;-- 결과: 채널별 주문 수를 세 행으로 세로 결합
컬럼 수와 데이터 타입이 일치해야 한다. 컬럼명은 첫 쿼리 기준
성능은 항상 UNION ALL > UNION — 중복 제거 비용 없음. 중복 제거가 불필요하면 UNION ALL 사용
전체 정렬은 마지막 쿼리 뒤에 ORDER BY 한 번만
15 LIMIT OFFSET — 페이지네이션
-- 상위 N개 조회SELECT*FROM products ORDERBY price DESCLIMIT5;-- 페이지네이션 (페이지 크기 10, 3페이지)SELECT*FROM products ORDERBYidLIMIT10 OFFSET 20;-- LIMIT {페이지 크기} OFFSET {(페이지 번호 - 1) * 페이지 크기}-- MySQL 단축 문법 (OFFSET, LIMIT 순서 주의)SELECT*FROM products ORDERBYidLIMIT20, 10; -- OFFSET 20, LIMIT 10
ORDER BY없이LIMIT만 쓰면 결과가 비결정적. 반드시 정렬 기준과 함께 사용
대용량에서 큰 OFFSET은 비효율적 — 커서 기반 페이지네이션(WHERE id > last_id LIMIT N)이 현업 표준
16 COALESCE — 다중 NULL 대체
-- COALESCE: 첫 번째 NULL이 아닌 값 반환 (IFNULL의 다중 값 버전)SELECTCOALESCE(nickname, name, '익명') AS display_name FROM users;-- nickname이 NULL이면 name, name도 NULL이면 '익명' 반환-- NULL을 0으로 채워 연산SELECT NAME,COALESCE(score, 0) AS score -- score NULL → 0FROM students;