SQL 튜터 세션 정리

SQL
LeetCode
윈도우 함수 + DENSE_RANK 패턴, MySQL 문자열 함수 패밀리, LEFT JOIN으로 “누락 없이 보존” 패턴, 3-값 논리(NULL은 비교 불가), 집계 함수의 빈 입력 처리 규칙
Published

2026.04.23

SQL 튜터 세션 정리

날짜: 2026-04-23


오늘 푼 문제

# 문제 난이도 핵심 개념
1 LeetCode 185. Department Top Three Salaries Hard DENSE_RANK + PARTITION BY + CTE, top N unique 패턴, WHERE에서 윈도우 함수 참조 불가
2 LeetCode 1667. Fix Names in a Table Easy CONCAT + UPPER/LOWER + SUBSTRING/LEFT, SUBSTRING 길이 인자 생략
3 LeetCode 1251. Average Selling Price Easy LEFT JOIN으로 마스터 보존, BETWEEN 날짜 매칭, IFNULL/NULLIF로 0 나누기 방어
4 LeetCode 577. Employee Bonus Easy LEFT JOIN + IS NULL, SQL의 3-값 논리, NULL 비교는 IS NULL/IS NOT NULL
5 LeetCode 619. Biggest Single Number Easy GROUP BY + HAVING COUNT, 집계 함수의 빈 입력 처리 (MAX → NULL, COUNT → 0)

문제 1: LeetCode 185. Department Top Three Salaries

문제 테이블

Employee(id INT PK, name VARCHAR, salary INT, departmentId INT FK)
Department(id INT PK, name VARCHAR)

목표: 각 부서별로 상위 3개 unique 연봉을 받는 직원을 모두 반환. 동률이면 모두 포함.

풀이 코드

WITH t AS (
    SELECT d.name AS Department,
           e.name AS Employee,
           e.salary AS Salary,
           DENSE_RANK() OVER(PARTITION BY departmentId ORDER BY salary DESC) AS rnk
    FROM Employee e
    LEFT JOIN Department d ON e.departmentId = d.id
)
SELECT Department, Employee, Salary
FROM t
WHERE rnk <= 3
  • “동률인 경우에는 같이 나올 수 있네. 즉, DENSE_RANK 또는 RANK 중 하나”라고 문제를 읽자마자 파악한 게 풀이의 절반. “top 3 unique salaries”라는 조건을 정확히 RANK vs DENSE_RANK 선택 기준으로 매핑함
  • WHERE 절이 SELECT보다 먼저 실행되므로 윈도우 함수 결과(rnk)를 같은 SELECT의 WHERE에서 참조 불가 → CTE/서브쿼리로 한 번 감싸서 윈도우 함수가 평가된 “후의 가상 테이블”을 만든 다음 WHERE 적용. 본인 코드가 정확히 그 구조

핵심 개념 정리

  • RANK vs DENSE_RANK: 동률 발생 시 다음 순위를 건너뛰는지(RANK: 1,2,2,4) vs 건너뛰지 않는지(DENSE_RANK: 1,2,2,3). “top N unique” 문제는 DENSE_RANK
  • 윈도우 함수의 평가 시점: SELECT 단계에서 평가됨 → 같은 SELECT의 WHERE/HAVING에서 참조 불가 → 한 단계 감싸야 함
  • LEFT JOIN vs INNER JOIN (FK 관계가 있을 때): FK가 매칭되지 않거나 NULL인 행이 없다고 보장된다면 둘 다 같은 결과. 의도가 명확한 INNER JOIN이 더 표현력 있음

다른 풀이 (학습용)

SELECT d.name AS Department,
       e.name AS Employee,
       e.salary AS Salary
FROM Employee e
INNER JOIN Department d ON e.departmentId = d.id
WHERE 3 > (
    SELECT COUNT(DISTINCT e2.salary)
    FROM Employee e2
    WHERE e2.salary > e.salary
      AND e2.departmentId = e.departmentId
)
  • “자기 자신보다 높은 unique 연봉의 개수가 0/1/2개인 사람만 통과시키는” 상관 서브쿼리(correlated subquery) 패턴. 윈도우 함수가 없던 MySQL 5.x 시절 표준 풀이
  • 윈도우 함수 풀이 vs 상관 서브쿼리 풀이 트레이드오프: 가독성·성능은 윈도우 함수 우세, 호환성은 상관 서브쿼리 우세 (MySQL 5.7 이하 포함)

다음에 공부할 것


문제 2: LeetCode 1667. Fix Names in a Table

문제 테이블

Users(user_id INT PK, name VARCHAR)
-- name은 대소문자가 섞여 있음

목표: 첫 글자만 대문자, 나머지는 소문자로 정규화. user_id 오름차순 정렬.

풀이 코드

SELECT user_id, 
       CONCAT(UPPER(SUBSTRING(name, 1, 1)),
              LOWER(SUBSTRING(name, 2))
              ) AS name
FROM Users
ORDER BY user_id
  • “함수를 모른다”라고 했지만 LEFT, SUBSTRING, LENGTH, UPPER, LOWER, CONCAT을 다 떠올려서 조합한 게 인상적. 풀이의 90%는 이미 머릿속에 있던 셈
  • 첫 시도에서 괄호 짝이 어긋난 것을 본인이 직접 들여쓰기로 시각화하면서 잡아냄
  • SUBSTRING(name, 2)로 길이 인자를 생략한 게 깔끔. MySQL은 길이를 안 적으면 시작 위치부터 끝까지 자동 추출
  • 문제에 명시된 ORDER BY user_id를 챙긴 점도 좋음. LeetCode 채점은 ORDER BY 없어도 통과되는 경우가 많지만, 요구사항을 정확히 반영하는 습관

핵심 개념 정리

  • 문자열 함수 패밀리:
    • LEFT(str, N) / RIGHT(str, N) — 왼쪽/오른쪽에서 N글자
    • SUBSTRING(str, 시작, 길이) — 중간 어딘가부터 N글자. 길이 생략 시 끝까지
    • UPPER(str) / LOWER(str) — 대소문자 변환
    • CONCAT(...) — 문자열 연결
    • LENGTH(str) (바이트 수) vs CHAR_LENGTH(str) (문자 수) — 한글·이모지 등 멀티바이트 문자가 섞이면 결과가 달라짐. 한글이 들어갈 수 있는 컬럼에는 CHAR_LENGTH가 안전
  • 함수 중첩 시 들여쓰기: 괄호 짝 맞추기 실수를 시각적으로 잡아내는 가장 빠른 방법

다음에 공부할 것


문제 3: LeetCode 1251. Average Selling Price

문제 테이블

Prices(product_id, start_date, end_date, price)
-- PK: (product_id, start_date, end_date), 같은 product_id가 여러 행
UnitsSold(product_id, purchase_date, units)
-- 중복 행 가능

목표: 각 상품별 평균 판매 단가 (가중평균: SUM(units*price)/SUM(units)). 판매 이력이 없으면 0.

풀이 코드

최종 풀이 — CTE + LEFT JOIN + IFNULL

WITH Average AS (
    SELECT p.product_id,
        us.purchase_date,
        us.units AS units, 
        p.price AS price
    FROM Prices p
    LEFT JOIN UnitsSold us 
        ON p.product_id = us.product_id
        AND us.purchase_date >= p.start_date 
        AND us.purchase_date <= p.end_date
)
SELECT product_id, ROUND(IFNULL(SUM(units * price) / SUM(units), 0), 2) AS average_price 
FROM Average
GROUP BY product_id 
  • 풀이 전에 주석으로 “안전하게 모든 empid를 담는 게 중요하겠다”라고 미리 설계 방향을 잡고 들어간 점이 핵심. “팔린 적 없는 상품도 0으로 살려야 한다”는 요구사항을 정확히 캐치
  • 처음에는 FROM (SELECT DISTINCT product_id FROM Prices) p로 unique product 목록을 만들었는데, 그 가상 테이블에 start_date, end_date, price가 없어서 ON 절·SELECT 절 컬럼 참조가 실패. 본인이 “p에 price를 추가하면 안되지 않아?”라고 직접 짚어내며 방향 전환
  • 시작점을 Prices p로 바꾸고, GROUP BY로 product_id를 합치는 방향이 더 자연스럽다는 점을 스스로 도출
  • CTE 안의 COALESCE(us.units, 0)을 처음 넣었다가, 0으로 바꾸면 분모가 0이 되어 오히려 함정이 됨을 인지하고 제거. NULL인 채로 두면 SUM이 NULL을 무시해서 분모가 NULL → IFNULL이 처리

핵심 개념 정리

  • 시작점 선택의 중요성: “팔린 적 없는 항목까지 결과에 포함” 요구사항이 있으면, 마스터 테이블(Prices)을 시작점으로 잡고 LEFT JOIN으로 트랜잭션 테이블(UnitsSold)을 붙이는 방향이 자연스러움
  • LEFT JOIN + 집계 + IFNULL = 표준 패턴: 매출 0인 상품, 주문 없는 고객, 학생 없는 강의 등 “기준 항목은 보존하고 집계는 0/NULL로”
  • 0으로 나누기 방어 두 가지 방법:
    • IFNULL(분자/분모, 0) — 분모가 NULL일 때만 안전
    • IFNULL(분자/NULLIF(분모, 0), 0) — 분모가 NULL이든 0이든 안전 (실무 권장)
  • 불필요한 COALESCE는 함정: NULL을 0으로 미리 바꾸면 오히려 분모를 0으로 만들 수 있음. NULL은 NULL인 채로 두는 게 SUM/AVG에 유리할 때가 있음
  • BETWEEN A AND B = >= A AND <= B. 의도 표현이 더 명확

다음에 공부할 것


문제 4: LeetCode 577. Employee Bonus

문제 테이블

Employee(empId INT PK, name VARCHAR, supervisor INT, salary INT)
Bonus(empId INT PK FK, bonus INT)

목표: 보너스가 1000 미만이거나 받지 않은 직원의 이름과 보너스 금액.

풀이 코드

SELECT e.name, b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE b.bonus < 1000 OR b.bonus IS NULL
  • 직전 1251번에서 익힌 “마스터 테이블 시작 + LEFT JOIN + NULL 처리” 패턴을 그대로 적용. 사고 흐름의 연속성이 좋음
  • “안전하게 모든 empid 를 담는 게 중요하겠다”라는 본인 주석에서 시작점 선택을 의식적으로 함
  • WHERE b.bonus < 1000 OR b.bonus IS NULL — 두 조건을 OR로 연결. “1000 미만 OR 보너스 없음”이라는 문제 요구사항을 그대로 SQL로 옮긴 직관적 표현

핵심 개념 정리

  • SQL의 3-값 논리 (three-valued logic): TRUE / FALSE / NULL(UNKNOWN). 일반 프로그래밍 언어의 boolean 2-값 논리와 다른 지점

  • NULL이 끼면 비교 연산은 거의 다 NULL을 반환:

    NULL = NULL    -- NULL (TRUE 아님!)
    NULL = 0       -- NULL
    NULL <> NULL   -- NULL
    NULL < 1000    -- NULL
    NULL IS NULL   -- TRUE  ← 유일하게 비교 가능
  • WHERE 절은 “조건이 TRUE인 행만” 통과시키므로, NULL을 체크할 때는 반드시 IS NULL / IS NOT NULL

  • 컬럼 alias 명시 습관 (e.name, b.bonus): 컬럼이 한 테이블에만 있어도 어느 테이블에서 왔는지 명시하면 가독성·유지보수성 향상

다른 풀이 (학습용)

-- COALESCE로 NULL을 큰 수로 바꿔서 비교
WHERE COALESCE(b.bonus, 0) < 1000

가능하지만, 본인이 쓴 < 1000 OR IS NULL이 의도가 가장 명확. 코드는 짧은 것보다 의도가 읽히는 게 좋음.

다음에 공부할 것


문제 5: LeetCode 619. Biggest Single Number

문제 테이블

MyNumbers(num INT)
-- PK 없음, 중복 가능

목표: 정확히 한 번만 등장한 수 중 가장 큰 값. 그런 수가 없으면 NULL.

풀이 코드

본인 풀이 — 명시적 CASE WHEN

SELECT CASE WHEN COUNT(*) = 0 THEN NULL ELSE MAX(num) END AS num
FROM (SELECT num
        FROM MyNumbers
        GROUP BY num
        HAVING COUNT(num) = 1
        ) t

더 짧은 풀이 — MAX의 빈 입력 처리에 의존

SELECT MAX(num) AS num
FROM (SELECT num
      FROM MyNumbers
      GROUP BY num
      HAVING COUNT(num) = 1
     ) t
  • 풀이를 마친 직후 본인이 던진 메타 질문이 핵심: “근데, 없는 경우에는? MAX 가 어떻게 동작하는 걸까? 아니 애초에, FROM t 가 빈 값이다.” — 정답을 찾는 것 이상으로 “왜 이게 동작하는지”를 의식하는 단계로 들어감
  • CASE WHEN 풀이도 정답이고, 빈 입력 처리를 의식적으로 했다는 점에서 의미 있음. 다만 SQL 집계 함수의 표준 동작에 의존하면 한 줄로 줄일 수 있음

핵심 개념 정리

  • SQL 집계 함수의 빈 입력 처리 규칙:
    • MAX, MIN, SUM, AVG → 입력이 0행이면 NULL 반환 (1행짜리 결과에 NULL)
    • COUNT → 입력이 0행이면 0 반환 (예외!)
  • “0행 입력” ≠ “결과 0행”. 집계 함수는 항상 1행을 반환, 그 값이 NULL일 뿐
  • 이 규칙 덕분에 본 문제는 외부 CASE WHEN 없이 MAX(num)만으로 요구사항(없으면 NULL) 충족
  • HAVING COUNT(*) = 1 vs HAVING COUNT(num) = 1:
    • COUNT(*) — 모든 행 (NULL 포함)
    • COUNT(num) — num이 NULL이 아닌 행만
    • num이 NULL일 수 있다면 결과가 달라짐. 일반적으로 GROUP BY + HAVING COUNT() = 1 패턴은 COUNT(*)가 안전

다음에 공부할 것


오늘의 메타 정리

⭐ 기억에 남은 인사이트

  1. 사고 흐름의 연속성 — 1251번에서 익힌 “마스터 테이블 시작 + LEFT JOIN + NULL 처리” 패턴이 바로 다음 577번에 자연스럽게 적용됨. 한 번에 익힌 패턴을 다음 문제로 확장하는 능력이 자라고 있음
  2. 메타 질문이 학습을 가속화 — 619번에서 “MAX가 빈 입력에서 어떻게 동작하지?”라고 스스로 의문을 제기한 게 단순 정답 찾기를 넘어선 학습. 정답을 맞히는 것보다 “왜 동작하는지”를 의식하는 단계로 진입
  3. 주석으로 사전 설계 — 모든 문제에서 SQL을 쓰기 전에 한국어 주석으로 사고 흐름을 풀어쓰는 습관이 자리잡힘. 이 습관 덕분에 막혔을 때 어디서 헷갈렸는지 정확히 짚을 수 있음

잘한 점

  • 185번에서 “RANK or DENSE_RANK”를 문제 읽자마자 후보로 떠올림 — 윈도우 함수 패턴이 체화되고 있음
  • 1251번에서 첫 풀이가 막혔을 때 “p에 price 추가하면 안 되지 않아?”라고 본인이 직접 잘못된 방향을 짚어내고 전환
  • 문자열 함수를 “모른다”라고 하면서도 머릿속에 이미 6개를 떠올린 점 — 직관과 자신감의 격차 인지 필요
  • 619번에서 정답을 낸 후에도 “이게 왜 동작하지?”라는 메타 질문을 던진 것

새로 익힌 함수·문법

  • DENSE_RANK() OVER (PARTITION BY ... ORDER BY ...) — top N unique 문제의 표준 도구
  • LEFT(str, N) / RIGHT(str, N) / SUBSTRING(str, 시작, 길이?) — 문자열 추출 패밀리
  • LENGTH (바이트) vs CHAR_LENGTH (문자 수) 구분
  • IFNULL(분자/NULLIF(분모, 0), 0) — 0으로 나누기 안전 패턴
  • IS NULL / IS NOT NULL — 3-값 논리에서 유일하게 NULL 비교가 가능한 연산자

다음에 공부할 것 (오늘자 통합)