SQL 튜터 세션 정리
SQL
LeetCode
윈도우 함수 + DENSE_RANK 패턴, MySQL 문자열 함수 패밀리, LEFT JOIN으로 “누락 없이 보존” 패턴, 3-값 논리(NULL은 비교 불가), 집계 함수의 빈 입력 처리 규칙
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)(바이트 수) vsCHAR_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(*) = 1vsHAVING COUNT(num) = 1:COUNT(*)— 모든 행 (NULL 포함)COUNT(num)— num이 NULL이 아닌 행만- num이 NULL일 수 있다면 결과가 달라짐. 일반적으로
GROUP BY+HAVING COUNT() = 1패턴은COUNT(*)가 안전
다음에 공부할 것
오늘의 메타 정리
⭐ 기억에 남은 인사이트
- 사고 흐름의 연속성 — 1251번에서 익힌 “마스터 테이블 시작 + LEFT JOIN + NULL 처리” 패턴이 바로 다음 577번에 자연스럽게 적용됨. 한 번에 익힌 패턴을 다음 문제로 확장하는 능력이 자라고 있음
- 메타 질문이 학습을 가속화 — 619번에서 “MAX가 빈 입력에서 어떻게 동작하지?”라고 스스로 의문을 제기한 게 단순 정답 찾기를 넘어선 학습. 정답을 맞히는 것보다 “왜 동작하는지”를 의식하는 단계로 진입
- 주석으로 사전 설계 — 모든 문제에서 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(바이트) vsCHAR_LENGTH(문자 수) 구분IFNULL(분자/NULLIF(분모, 0), 0)— 0으로 나누기 안전 패턴IS NULL/IS NOT NULL— 3-값 논리에서 유일하게 NULL 비교가 가능한 연산자