SQL 필수 구문 레퍼런스 (Level 2)

코딩 테스트 SQL Level 2를 위한 JOIN·GROUP BY 정리

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_OUT
FROM ANIMAL_INS i
INNER JOIN ANIMAL_OUTS o ON i.ANIMAL_ID = o.ANIMAL_ID;
-- 결과: A001(Toto), A003(Coco) 2행만 — A002(Mimi)는 OUTS에 없으므로 제외

-- LEFT JOIN: 왼쪽 테이블 전체 + 오른쪽은 매칭되면 값, 없으면 NULL
SELECT i.ANIMAL_ID, i.NAME, o.DATETIME_OUT
FROM ANIMAL_INS i
LEFT JOIN 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.NAME
FROM ANIMAL_INS i
LEFT JOIN ANIMAL_OUTS o ON i.ANIMAL_ID = o.ANIMAL_ID
WHERE o.ANIMAL_ID IS NULL;
-- 결과: A002(Mimi) — OUTS에 없는 행

-- 다중 JOIN
SELECT a.name, b.dept, c.salary
FROM employees a
JOIN departments b ON a.dept_id = b.id
JOIN 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,4000

SELECT customer_id, COUNT(*) AS order_cnt, SUM(amount) AS total
FROM orders
GROUP BY customer_id;
-- 결과:
-- customer_id | order_cnt | total
-- ------------|-----------|------
-- C001        | 2         | 5000
-- C002        | 2         | 6000
-- C003        | 1         | 4000

-- HAVING: 집계 후 조건 (2회 이상 주문한 고객)
SELECT customer_id, COUNT(*) AS cnt
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 2;
-- 결과: C001(2), C002(2) — C003은 1회라 제외

-- WHERE vs HAVING
SELECT customer_id, COUNT(*) AS cnt
FROM orders
WHERE amount >= 2000        -- 집계 전 필터: 2000원 이상 주문만 대상
GROUP BY customer_id
HAVING COUNT(*) >= 2;       -- 집계 후 필터: 2회 이상인 고객만

5 CASE WHEN

-- CASE WHEN: 조건부 값 반환 (if-else와 동일)
SELECT
    NAME,
    CASE
        WHEN SEX_UPON_INTAKE LIKE 'Intact%' THEN '중성화 전'
        WHEN SEX_UPON_INTAKE LIKE 'Spayed%' THEN '중성화 완료'
        WHEN SEX_UPON_INTAKE LIKE 'Neutered%' THEN '중성화 완료'
        ELSE '알 수 없음'
    END AS 중성화_여부
FROM ANIMAL_INS;
-- 결과: 각 동물의 중성화 상태를 한국어로 변환

-- CASE WHEN으로 그룹 집계
SELECT
    CASE
        WHEN salary >= 5000 THEN '고연봉'
        WHEN salary >= 3000 THEN '중연봉'
        ELSE '저연봉'
    END AS 연봉_등급,
    COUNT(*) AS 직원수
FROM employees
GROUP BY 연봉_등급;
-- 결과: 연봉 등급별 직원 수

6 서브쿼리

-- WHERE 절 서브쿼리: 집계 결과를 조건으로 활용
SELECT NAME, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- 결과: 평균 급여 이상인 직원 (AVG가 먼저 계산되어 비교값으로 사용됨)

-- IN + 서브쿼리
SELECT NAME FROM ANIMAL_INS
WHERE ANIMAL_ID IN (
    SELECT ANIMAL_ID FROM ANIMAL_OUTS
);
-- 결과: 입양된 동물의 이름 (INNER JOIN과 동일 결과)

-- 스칼라 서브쿼리: SELECT 절에서 단일 값 반환
SELECT
    NAME,
    salary,
    (SELECT AVG(salary) FROM employees) AS avg_salary,
    salary - (SELECT AVG(salary) FROM employees) AS diff
FROM 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, name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id    -- 상관조건: 바깥 행과 연결
);
-- 결과: C001(Kim), C002(Lee)

-- NOT EXISTS: 주문이 한 번도 없는 고객
SELECT customer_id, name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
);
-- 결과: C003(Park)
  • EXISTS는 서브쿼리가 한 행이라도 반환하면 TRUE. SELECT 리스트는 SELECT 1이 관용이며 실제 컬럼값은 평가하지 않는다
  • NOT EXISTSNOT IN과 달리 NULL-safe. 서브쿼리 결과에 NULL이 섞여도 올바르게 동작한다 (NOT IN은 NULL이 끼면 전체 UNKNOWN)
  • JOIN·IN·EXISTS의 선택 기준: (1) 존재 여부만 필요 → EXISTS, (2) 반환 컬럼도 필요 → JOIN, (3) 작은 상수 집합 비교 → IN

8 FIELD — 문자열 범주 논리 정렬 (MySQL)

-- 알파벳 순이 아닌 논리 순서로 정렬
SELECT category, price_range, product_count
FROM category_summary
ORDER BY FIELD(price_range, 'LOW', 'MID', 'HIGH');
-- FIELD(col, v1, v2, v3) → v1이면 1, v2면 2, v3이면 3 반환
-- 결과: LOW → MID → HIGH 순 (알파벳 순 HIGH/LOW/MID 아님)

-- 등급·우선순위 정렬
ORDER BY FIELD(priority, 'URGENT', 'HIGH', 'NORMAL', 'LOW');
  • FIELDMySQL 전용. 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 비교
SELECT COALESCE(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_price
FROM multi_channel_price;

-- 날짜 비교: 두 날짜 중 더 최근
SELECT GREATEST(last_login, last_order) AS last_active FROM users;
  • GREATEST/LEAST같은 행의 여러 컬럼을 비교 (수평 비교). MAX/MIN여러 행의 한 컬럼을 집계 (수직 비교)
  • 인자 중 하나라도 NULL이면 결과 NULL. NULL 방어는 COALESCE로 감싼다

11 문자열 함수

-- CONCAT: 문자열 결합
SELECT CONCAT(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글자)
SELECT LEFT('abcdef', 3);                 -- → 'abc'
SELECT RIGHT('abcdef', 3);                -- → 'def'

-- LENGTH vs CHAR_LENGTH: 바이트 수 vs 글자 수
SELECT LENGTH('한글'), CHAR_LENGTH('한글');
-- LENGTH=6 (UTF-8 3바이트×2), CHAR_LENGTH=2
-- 한글 포함 시 CHAR_LENGTH가 의도에 부합

-- REPLACE: 치환
SELECT REPLACE('010-1234-5678', '-', '');    -- → '01012345678'

-- LOWER / UPPER: 대소문자 변환
SELECT LOWER(email) FROM users;

-- LIKE 와일드카드: %=0개이상, _=정확히1개
WHERE name LIKE 'K%'      -- K로 시작
WHERE name LIKE '%son'    -- son으로 끝
WHERE name LIKE '_im'     -- 첫글자+im (3글자)

12 숫자 함수

-- ROUND: 반올림 (2번째 인자: 소수 자리)
SELECT ROUND(3.14159, 2);    -- → 3.14
SELECT ROUND(1234.5, -2);    -- → 1200 (10의 자리)

-- CEIL / CEILING: 올림, FLOOR: 내림, TRUNCATE: 버림 (반올림 아님)
SELECT CEIL(3.2),  FLOOR(3.8), TRUNCATE(3.78, 1);    -- → 4, 3, 3.7

-- MOD: 나머지 (MOD(a, b) 또는 a % b)
SELECT MOD(10, 3);    -- → 1
WHERE MOD(id, 2) = 0;    -- 짝수 ID만

-- ABS: 절댓값, POWER: 거듭제곱, SQRT: 제곱근
SELECT ABS(-5), POWER(2, 10), SQRT(16);    -- → 5, 1024, 4
  • 평균 반올림 패턴: ROUND(AVG(col), 2) — Programmers 매우 빈출
  • TRUNCATE는 반올림이 아니라 버림 — 혼동 주의

13 날짜 계산 함수

-- DATEDIFF: 날짜 차이 (일 수)
SELECT DATEDIFF('2022-03-15', '2022-01-01');    -- → 73 (일)

SELECT NAME,
       DATEDIFF(DATETIME_OUT, DATETIME_IN) AS days_stayed
FROM ANIMAL_INS
JOIN ANIMAL_OUTS USING (ANIMAL_ID);
-- 결과: 각 동물의 보호 기간 (일)

-- DATE_ADD / DATE_SUB: 날짜 더하기/빼기
SELECT DATE_ADD('2022-01-01', INTERVAL 30 DAY);    -- → '2022-01-31'
SELECT DATE_SUB('2022-03-15', INTERVAL 1 MONTH);   -- → '2022-02-15'

-- NOW(): 현재 시각
SELECT * FROM orders WHERE DATETIME >= DATE_SUB(NOW(), INTERVAL 7 DAY);
-- 최근 7일 이내 주문 조회

-- YEAR / MONTH / DAY / DAYOFWEEK / HOUR: 날짜 부분 추출
SELECT YEAR('2026-04-15'), MONTH('2026-04-15'), DAY('2026-04-15');
-- → 2026, 4, 15
SELECT DAYOFWEEK('2026-04-15');    -- → 4 (일=1, 월=2, ..., 토=7)

-- 월별·연도별 집계 패턴 (매우 빈출)
SELECT YEAR(order_date) AS yr, MONTH(order_date) AS mo, COUNT(*) AS cnt
FROM orders
GROUP BY YEAR(order_date), MONTH(order_date)
ORDER BY 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_customers
UNION
SELECT name FROM regular_customers;

-- UNION ALL: 중복 허용 결합 (성능 우수)
SELECT 'web' AS channel, COUNT(*) FROM web_orders
UNION ALL
SELECT 'app' AS channel, COUNT(*) FROM app_orders
UNION ALL
SELECT 'store' AS channel, COUNT(*) FROM store_orders;
-- 결과: 채널별 주문 수를 세 행으로 세로 결합
  • 컬럼 수와 데이터 타입이 일치해야 한다. 컬럼명은 첫 쿼리 기준
  • 성능은 항상 UNION ALL > UNION — 중복 제거 비용 없음. 중복 제거가 불필요하면 UNION ALL 사용
  • 전체 정렬은 마지막 쿼리 뒤에 ORDER BY 한 번만

15 LIMIT OFFSET — 페이지네이션

-- 상위 N개 조회
SELECT * FROM products ORDER BY price DESC LIMIT 5;

-- 페이지네이션 (페이지 크기 10, 3페이지)
SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 20;
-- LIMIT {페이지 크기} OFFSET {(페이지 번호 - 1) * 페이지 크기}

-- MySQL 단축 문법 (OFFSET, LIMIT 순서 주의)
SELECT * FROM products ORDER BY id LIMIT 20, 10;    -- OFFSET 20, LIMIT 10
  • ORDER BY 없이 LIMIT만 쓰면 결과가 비결정적. 반드시 정렬 기준과 함께 사용
  • 대용량에서 큰 OFFSET은 비효율적 — 커서 기반 페이지네이션(WHERE id > last_id LIMIT N)이 현업 표준

16 COALESCE — 다중 NULL 대체

-- COALESCE: 첫 번째 NULL이 아닌 값 반환 (IFNULL의 다중 값 버전)
SELECT COALESCE(nickname, name, '익명') AS display_name FROM users;
-- nickname이 NULL이면 name, name도 NULL이면 '익명' 반환

-- NULL을 0으로 채워 연산
SELECT
    NAME,
    COALESCE(score, 0) AS score      -- score NULL → 0
FROM students;

17 유형별 패턴 매핑 (Level 2)

문제 유형 핵심 구문 패턴
두 테이블 결합 INNER/LEFT JOIN FROM a JOIN b ON a.id = b.id
매칭 없는 행 찾기 LEFT JOIN + IS NULL WHERE b.id IS NULL
그룹별 조건 필터 HAVING GROUP BY col HAVING COUNT(*) > N
조건부 열 생성 CASE WHEN CASE WHEN 조건 THEN 값 ELSE 기본값 END
집계 결과 비교 서브쿼리 WHERE col > (SELECT AVG(col) ...)
보호 기간 계산 DATEDIFF DATEDIFF(out_date, in_date)
NULL 안전 연산 COALESCE COALESCE(col, 0)
존재 여부 검사 EXISTS / NOT EXISTS WHERE EXISTS (SELECT 1 FROM ...)
문자열 범주 정렬 FIELD ORDER BY FIELD(col, 'A', 'B', 'C')
월별·연도별 집계 YEAR/MONTH + GROUP BY GROUP BY YEAR(d), MONTH(d)
출력 포맷 맞추기 DATE_FORMAT DATE_FORMAT(d, '%Y-%m-%d')
평균 반올림 ROUND + AVG ROUND(AVG(col), 2)
0 나누기 방어 NULLIF a / NULLIF(b, 0)
페이지네이션 LIMIT OFFSET LIMIT N OFFSET M
쿼리 수직 결합 UNION ALL Q1 UNION ALL Q2

18 관련 문서

Subscribe

Enjoy this blog? Get notified of new posts by email: