데이터 엔지니어 인터뷰 — SQL

조인

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로 먼저 줄이는 편이 빠릅니다.