조인
INNER, LEFT, FULL OUTER 조인의 차이를 설명해보세요
INNER는 양쪽에 모두 매칭되는 행만, LEFT는 왼쪽 전체 + 매칭되는 오른쪽(없으면 NULL), FULL OUTER는 양쪽 전체를 남깁니다.
면접에서 자주 확인하는 건 행 수 변화입니다. 조인 키가 오른쪽에서 유일하지 않으면 LEFT JOIN이어도 왼쪽 행이 늘어납니다. “LEFT JOIN이니까 행 수가 유지된다”는 흔한 오해입니다.
-- orders 1건에 대해 items 3건이 매칭되면 결과는 3행
SELECT o.order_id, i.sku
FROM orders o LEFT JOIN items i ON o.order_id = i.order_id;
ON 절과 WHERE 절에 조건을 넣는 것은 어떻게 다른가요
INNER JOIN에서는 결과가 같지만, OUTER JOIN에서는 완전히 다릅니다.
-- (A) ON 에 조건 — 매칭 실패해도 왼쪽 행은 남고 오른쪽만 NULL
SELECT * FROM orders o
LEFT JOIN payments p ON o.id = p.order_id AND p.status = 'PAID';
-- (B) WHERE 에 조건 — 조인 후 필터링. NULL 행이 걸러져 INNER JOIN 과 같아짐
SELECT * FROM orders o
LEFT JOIN payments p ON o.id = p.order_id
WHERE p.status = 'PAID';
(B)는 LEFT JOIN을 쓴 의도가 사라집니다. “결제 안 된 주문도 보고 싶다”면 (A)여야 합니다.
ANTI JOIN은 어떻게 표현하나요
“오른쪽에 없는 것”을 찾는 패턴입니다. 세 가지 방법이 있고 NULL 처리가 갈립니다.
-- LEFT JOIN + IS NULL
SELECT o.* FROM orders o
LEFT JOIN payments p ON o.id = p.order_id
WHERE p.order_id IS NULL;
-- NOT EXISTS — 권장
SELECT o.* FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id);
-- NOT IN — 위험
SELECT * FROM orders WHERE id NOT IN (SELECT order_id FROM payments);
NOT IN은 서브쿼리 결과에 NULL이 하나라도 있으면 전체 결과가 빈다는 함정이 있습니다. id NOT IN (1, 2, NULL)은 id != NULL을 포함하는데 이게 UNKNOWN이므로 어떤 행도 통과하지 못합니다. NOT EXISTS는 이 문제가 없습니다.
NULL
NULL을 다룰 때 주의할 점은 무엇인가요
NULL은 “값이 없음”이지 특정 값이 아니므로, 비교 연산의 결과가 참도 거짓도 아닌 UNKNOWN이 됩니다.
NULL = NULL은 참이 아닙니다.IS NULL을 써야 합니다WHERE col != 'A'는col이 NULL인 행을 제외합니다- 집계 함수는 NULL을 무시합니다.
COUNT(col)과COUNT(*)가 다른 이유입니다 SUM의 결과는 대상이 전부 NULL이면 0이 아니라 NULL입니다
SELECT COUNT(*), -- 전체 행 수
COUNT(col), -- col 이 NULL 이 아닌 행 수
COUNT(DISTINCT col)
FROM t;
COALESCE와 NULLIF는 언제 쓰나요
COALESCE(a, b, c)는 첫 번째 NULL이 아닌 값을 돌려줍니다. 기본값 채우기에 씁니다.
NULLIF(a, b)는 두 값이 같으면 NULL을 돌려줍니다. 0으로 나누기 방지에 자주 씁니다.
SELECT revenue / NULLIF(orders, 0) AS aov FROM daily;
윈도우 함수
GROUP BY와 윈도우 함수의 차이는 무엇인가요
GROUP BY는 여러 행을 하나로 접습니다. 윈도우 함수는 행 수를 유지한 채 각 행에 집계 결과를 붙입니다.
-- 각 주문 행에 그 고객의 총 주문액을 함께 표시
SELECT order_id, customer_id, amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
ROW_NUMBER, RANK, DENSE_RANK의 차이는 무엇인가요
동점 처리 방식이 다릅니다.
| 값 | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 90 | 3 | 3 | 2 |
“그룹별 최신 1건”에는 ROW_NUMBER를 씁니다. RANK를 쓰면 동점일 때 여러 건이 나옵니다.
-- 주문별 최신 상태 1건
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn
FROM shipment_status
) WHERE rn = 1;
누적합과 이동평균을 써보세요
프레임 지정이 핵심입니다.
SELECT dt, amount,
-- 누적합
SUM(amount) OVER (ORDER BY dt
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running,
-- 7일 이동평균
AVG(amount) OVER (ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales;
ORDER BY만 쓰고 프레임을 생략하면 기본값이 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW인데, RANGE는 동일한 정렬 값을 한 덩어리로 취급합니다. 같은 날짜가 여러 건이면 의도와 다르게 나오므로 ROWS를 명시하는 편이 안전합니다.
LAG와 LEAD는 어디에 쓰나요
이전/다음 행의 값을 가져옵니다. 변화량 계산, 상태 전이 탐지에 씁니다.
SELECT device_id, measured_at, value,
value - LAG(value) OVER (PARTITION BY device_id ORDER BY measured_at) AS delta
FROM telemetry;
누적 카운터에 이 방식을 쓰면 장비 재시작 시 큰 음수가 나옵니다. 그 경우를 따로 걸러야 합니다.
성능
인덱스가 있는데도 안 타는 경우는 언제인가요
인덱스 컬럼을 가공하면 못 씁니다.
-- 인덱스 미사용
WHERE DATE(created_at) = '2026-08-12'
WHERE SUBSTR(code, 1, 3) = 'ABC'
-- 인덱스 사용 가능
WHERE created_at >= '2026-08-12' AND created_at < '2026-08-13'
WHERE code LIKE 'ABC%'
그 밖에도 선행 와일드카드(LIKE '%abc'), 타입 불일치로 인한 암묵적 캐스팅, 카디널리티가 낮아 옵티마이저가 풀스캔이 낫다고 판단하는 경우가 있습니다.
복합 인덱스의 컬럼 순서는 왜 중요한가요
인덱스는 앞 컬럼부터 정렬되어 있으므로 선행 컬럼 없이는 사용할 수 없습니다. (a, b, c) 인덱스는 a, a+b, a+b+c 조건에 쓰이지만 b만으로는 못 씁니다.
등가 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 두는 것이 일반적입니다. 범위 조건이 걸린 컬럼 뒤쪽은 정렬이 보장되지 않기 때문입니다.
실행 계획에서 무엇을 보나요
- 접근 방식 — 풀스캔인지 인덱스 스캔인지
- 예상 행 수 vs 실제 행 수 — 크게 벌어지면 통계가 낡았다는 신호
- 조인 순서와 방식 — 큰 테이블이 먼저 나오면 의심
- 정렬·해시 작업 — 임시 테이블이나 디스크 정렬이 걸리는지
EXPLAIN ANALYZE SELECT ...; -- 실제 실행 후 실측치 포함
COUNT(*)와 COUNT(1)은 다른가요
대부분의 DBMS에서 동일하게 처리됩니다. 성능 차이는 없습니다. COUNT(col)만 다릅니다 — NULL을 제외합니다.
중복을 제거하는 여러 방법을 비교해보세요
-- 1) DISTINCT — 전체 컬럼 조합 기준
SELECT DISTINCT a, b FROM t;
-- 2) GROUP BY — 집계와 함께 쓸 때
SELECT a, b, MAX(c) FROM t GROUP BY a, b;
-- 3) ROW_NUMBER — 어떤 행을 남길지 고를 수 있음
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY a, b ORDER BY updated_at DESC) rn FROM t
) WHERE rn = 1;
“중복 제거”라고 할 때 실제로 원하는 것이 3번인 경우가 많습니다. DISTINCT는 어떤 행이 남는지 고를 수 없습니다.
자주 나오는 문제
연속된 날짜를 찾는 쿼리를 짜보세요
날짜 - ROW_NUMBER()가 연속 구간에서 상수가 된다는 성질을 씁니다(gaps and islands).
SELECT user_id, MIN(dt) AS start_dt, MAX(dt) AS end_dt, COUNT(*) AS days
FROM (
SELECT user_id, dt,
DATE_SUB(dt, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY dt) DAY) AS grp
FROM login_log
) t
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;
상위 N개를 그룹별로 뽑아보세요
SELECT category, product, revenue FROM (
SELECT category, product, revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM sales
) WHERE rn <= 3;
LIMIT은 전체에 대해 동작하므로 그룹별로는 쓸 수 없습니다.
피벗을 SQL로 표현해보세요
조건부 집계가 표준적인 방법입니다.
SELECT dt,
SUM(CASE WHEN status = 'PAID' THEN amount ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'REFUNDED' THEN amount ELSE 0 END) AS refunded
FROM orders GROUP BY dt;
HAVING과 WHERE는 어떻게 다른가요
WHERE는 집계 전에 행을 거르고, HAVING은 집계 후에 그룹을 거릅니다.
SELECT customer_id, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2026-01-01' -- 집계 대상을 줄임
GROUP BY customer_id
HAVING COUNT(*) >= 10; -- 집계 결과로 그룹을 거름
가능하면 WHERE로 먼저 줄이는 편이 빠릅니다.