안티조인, 상관 서브쿼리, 그룹별 Top-N, 연속값 탐지까지 — 조인의 실전 패턴을 총정리한다.
링크: https://leetcode.cn/problems/product-sales-analysis-iii/
학습 포인트: 그룹별 최소값을 서브쿼리로 구한 뒤 튜플 IN으로 매칭하기.
Sales(sale_id, product_id, year, quantity, price) — (sale_id, year)가 기본키Product(product_id, product_name)각 상품(product_id)에 대해 **가장 먼저 판매된 연도(첫 판매연도)**의 product_id, first_year, quantity, price를 구한다. 한 상품이 첫 해에 여러 건 팔렸다면 그 행을 모두 출력한다.
product_id별 MIN(year)다. 이를 서브쿼리로 뽑으면 (product_id, 첫해) 쌍의 목록이 나온다.Sales에서 그 쌍과 일치하는 행만 남기면 된다. MySQL은 (a, b) IN (SELECT ...) 형태의 행(튜플) 비교를 지원하므로 한 번에 매칭한다.Product 테이블은 이 문제에서 실제로 필요 없다(product_name을 요구하지 않음).(col1, col2) IN (subquery)로 두 컬럼을 동시에 매칭.GROUP BY로 대푯값을 구하고 원본과 다시 매칭하는 전형 패턴.MIN(year)만 SELECT하고 product_id를 빼면 어떤 상품의 최소연도인지 알 수 없어 매칭이 무너진다. 반드시 product_id와 함께 반환한다.GROUP BY product_id 후 quantity, price를 그냥 SELECT하면 첫해가 아닌 임의 행의 값이 섞일 수 있다. 집계로 해결하려 하지 말고 재조인/서브쿼리로 원본 행을 꺼내야 한다.링크: https://leetcode.cn/problems/project-employees-i/
학습 포인트: 조인 후 그룹 집계 + ROUND(AVG(...), 2).
Project(project_id, employee_id) — (project_id, employee_id)가 기본키Employee(employee_id, name, experience_years)각 프로젝트별로 소속 직원들의 평균 근속연수를 소수점 둘째 자리까지 반올림해 구한다.
Project는 프로젝트-직원 매핑만 가지고 근속연수가 없으므로, employee_id로 Employee를 조인해 experience_years를 붙인다.GROUP BY p.project_id.AVG(e.experience_years)는 부동소수 결과를 내므로 ROUND(..., 2)로 요구 형식(소수 둘째 자리)에 맞춘다.GROUP BY를 빼면 전체 평균 한 행만 나온다. 프로젝트별로 묶어야 한다.ROUND를 생략하면 3.3333... 같은 값이 나와 오답 처리된다.링크: https://leetcode.cn/problems/employees-earning-more-than-their-managers/
학습 포인트: 같은 테이블을 두 번 조인하는 셀프 조인.
Employee(id, name, salary, managerId) — managerId는 같은 테이블의 id를 가리킴(상사 없으면 NULL)자기 상사보다 급여가 높은 직원의 이름(Employee)을 구한다.
id만 있고 상사의 급여가 없다. 상사 급여를 알려면 같은 테이블을 한 번 더 참조해야 한다.e(직원)와 m(매니저) 두 별칭으로 셀프 조인: e.managerId = m.id로 직원 행에 그의 상사 행을 붙인다.WHERE e.salary > m.salary로 상사보다 급여 높은 직원만 남긴다.INNER JOIN이라 managerId가 NULL인 사장은 자동으로 제외된다(비교 대상이 없음). 문제 취지와 일치.Employee를 두 번 쓰면 컬럼 참조가 모호해진다. 반드시 별칭 부여.ON e.salary > m.salary처럼 섞지 말 것. 관계는 ON, 필터는 WHERE.링크: https://leetcode.cn/problems/exchange-seats/
학습 포인트: CASE로 홀짝 좌석을 교환하되 마지막 홀수 좌석은 그대로 두기.
Seat(id, student) — id는 연속된 정수(1부터), 기본키짝을 지어 홀수 id와 바로 다음 짝수 id의 학생을 서로 바꾼다. 즉 (1↔2), (3↔4), ... 학생이 홀수라 마지막 id가 짝을 못 이루면(전체 학생 수가 홀수) 그 좌석은 그대로 둔다. 결과는 id 순서로 출력.
ORDER BY id로 정렬하는 방식이 간결하다.+1(다음 짝수 자리로), 짝수 id는 -1(앞 홀수 자리로) 밀면 두 학생이 교환된다.+1을 하면 존재하지 않는 자리로 밀리므로, id = MAX(id)이면서 홀수이면 그대로 둔다. 이 조건을 CASE의 첫 분기에 두어 우선 처리한다.CASE는 위에서부터 첫 참 분기만 적용하므로 "마지막 홀수" 예외가 일반 홀수 규칙보다 먼저 검사되어야 한다.CASE 분기 순서를 뒤집어 일반 홀수 규칙을 먼저 두면 마지막 홀수도 +1되어 버린다.COUNT(*) OVER ()로 총 개수를 구해 비교하거나, LEAD/LAG로 옆자리 학생을 직접 당겨오는 방식도 가능(STEP 8에서 다룸).링크: https://leetcode.cn/problems/customers-who-never-order/
학습 포인트: 안티조인 — "존재하지 않는 것"을 찾는 세 가지 방법.
Customers(id, name)Orders(id, customerId)주문을 한 번도 하지 않은 고객의 이름을 구한다.
LEFT JOIN으로 모든 고객을 남기고, 주문이 있으면 Orders 컬럼이 채워지고 없으면 NULL이 된다.WHERE o.customerId IS NULL은 매칭되는 주문이 하나도 없던 고객만 골라낸다.NOT EXISTS: 상관 서브쿼리. NULL에 안전하고 대개 성능이 좋다.NOT IN: 서브쿼리 결과에 NULL이 하나라도 있으면 전체가 빈 결과가 되는 함정.LEFT JOIN ... IS NULL: 직관적이고 널리 쓰임.NOT IN을 쓸 때 Orders.customerId에 NULL이 섞이면 결과가 통째로 사라진다. 안전하게 쓰려면 WHERE customerId IS NOT NULL을 서브쿼리에 붙여야 한다.NOT EXISTS 버전(권장):
링크: https://leetcode.cn/problems/department-highest-salary/
학습 포인트: 그룹별 최대값과 튜플 매칭(동점 다수 포함).
Employee(id, name, salary, departmentId)Department(id, name)각 부서에서 급여가 가장 높은 직원(들)의 Department, Employee, Salary를 구한다. 동일 최고 급여자가 여러 명이면 모두 출력한다.
departmentId별 MAX(salary). 서브쿼리로 (부서, 최고급여) 쌍을 만든다.Employee에서 (departmentId, salary)가 그 쌍과 일치하는 직원을 튜플 IN으로 고른다. 최고 급여가 여러 명이면 모두 조건을 만족하므로 동점자 전원 출력.Department를 조인.(부서, MAX) 쌍과 일치하는 원본 행 추출.MAX(salary)만 뽑아 salary IN (...)로 비교하면 다른 부서의 최고 급여와도 매칭되어 오답이 된다. 반드시 departmentId와 짝지어 비교.링크: https://leetcode.cn/problems/department-top-three-salaries/
학습 포인트: 상관 서브쿼리로 "더 높은 서로 다른 급여의 개수"를 세어 Top-N 구하기.
Employee(id, name, salary, departmentId)Department(id, name)각 부서에서 급여 기준 상위 3개의 서로 다른(distinct) 급여에 해당하는 직원을 모두 구한다. 즉 부서 내에서 자기보다 높은 "서로 다른 급여" 값이 3개 미만이면 High Earner다. 같은 급여를 받는 사람이 여러 명이면 모두 포함.
이 문제의 핵심은 "상위 3개"를 순위가 아니라 '나보다 높은 급여 종류의 개수'로 재정의하는 것이다. 차근차근 보자.
e가 부서 내에서 몇 등인지 알려면, 같은 부서에서 e보다 급여가 높은 사람이 몇 명인지 세면 된다.COUNT(DISTINCT e2.salary).< 3이면 상위 3개 급여 안에 든다.e2.departmentId = e.departmentId가 바깥 행의 부서로 범위를 좁히는 상관 조건이다.COUNT(더 큰 값) < N 패턴: 윈도우 함수 없이 그룹별 Top-N을 구하는 고전 기법.COUNT(DISTINCT ...)가 아니라 그냥 COUNT(*)로 세면, 동일 급여 인원이 많은 부서에서 순위가 왜곡된다(같은 급여를 여러 등수로 계산).AND e2.salary > e.salary에서 >를 >=로 쓰면 자기 자신 포함으로 카운트가 하나 늘어 결과가 어긋난다.e2.departmentId = e.departmentId를 빠뜨리면 전 부서를 통틀어 비교하게 된다.DENSE_RANK() OVER (PARTITION BY departmentId ORDER BY salary DESC) <= 3이다. "서로 다른 급여" 요건 때문에 RANK가 아니라 DENSE_RANK를 써야 한다는 점이 포인트(STEP 8에서 상세히 다룬다).링크: https://leetcode.cn/problems/friend-requests-ii-who-has-the-most-friends/
학습 포인트: 두 방향 컬럼을 UNION ALL로 세로로 합쳐 카운트.
RequestAccepted(requester_id, accepter_id, accept_date) — (requester_id, accepter_id)가 기본키(수락된 요청만 존재)친구 관계는 양방향이다. 친구가 가장 많은 사람의 id와 친구 수 num을 구한다(정답은 유일하다고 가정).
requester_id도 친구가 한 명 늘고, accepter_id도 한 명 는다.id라는 이름으로 뽑아 UNION ALL로 세로로 쌓으면, 각 등장 횟수가 곧 그 사람의 친구 수가 된다.UNION이 아니라 **UNION ALL**을 써야 중복 제거 없이 모든 관계가 카운트된다.GROUP BY id로 사람별 집계 후 ORDER BY num DESC LIMIT 1로 최다 친구 보유자를 뽑는다.UNION은 중복 행을 제거하므로 카운트가 틀어진다. 반드시 UNION ALL.UNION을 쓰면 (A가 여러 명과 친구여도) 중복 제거로 수가 줄어 오답.링크: https://leetcode.cn/problems/tree-node/
학습 포인트: CASE + EXISTS로 노드 종류(Root/Inner/Leaf) 분류.
Tree(id, p_id) — p_id는 부모 노드의 id(루트는 NULL)각 노드를 다음으로 분류한다.
p_id가 NULL(부모 없음)p_id NULL 여부)와 "자식이 있는가"(누군가의 p_id로 등장하는가).CASE를 위에서부터 검사한다.
p_id IS NULL이면 Root로 확정.id가 다른 행의 p_id로 등장하면(= 자식을 가짐) Inner.id IN (SELECT p_id FROM Tree)로 판정한다. 부모 목록에 자기 id가 들어 있으면 자식이 있다는 뜻.p_id 집합에 자기 id가 있으면 자식 보유.id IN (SELECT p_id FROM Tree)에서 서브쿼리에 NULL(p_id NULL)이 섞여도 IN은 "일치"만 보므로 참/거짓 판정 자체는 문제없다. 다만 안전을 위해 WHERE p_id IS NOT NULL을 붙이면 의미가 명확하다.p_id IS NULL을 가장 먼저 검사하므로 올바르게 처리된다.EXISTS 버전:
t로 두어 상관 조건 사용)링크: https://leetcode.cn/problems/human-traffic-of-stadium/
학습 포인트: "3일 연속 조건" — 셀프 조인 세 벌로 연속 구간 탐지.
Stadium(id, visit_date, people) — id는 자동 증가(연속이라 보장되진 않지만 이 문제에선 날짜와 함께 증가)people >= 100인 날이 연속으로 3일 이상 이어진 모든 행을 id 순으로 출력한다. id가 연속(간격 1)이면 날짜가 연속이라고 본다.
이 문제는 어렵다. 핵심은 "연속 3일"을 세 행의 id가 1씩 차이 나는 조합으로 표현하는 것이다. 천천히 쌓아 보자.
people >= 100이어야 한다.n-1, n, n+1처럼 이어진 세 행이 존재하고, 그 셋이 모두 100명 이상이라는 뜻이다.t1이 3연속 블록에 속하는 경우는 그 행이 블록에서 어느 위치에 있느냐에 따라 세 가지다.
t1이 블록의 첫째: t1, t1+1, t1+2가 모두 조건 만족 → t1.id+1=t2.id AND t2.id+1=t3.idt1이 블록의 가운데: t1-1, t1, t1+1 → t2.id+1=t1.id AND t1.id+1=t3.idt1이 블록의 셋째: t1-2, t1-1, t1 → t3.id+1=t2.id AND t2.id+1=t1.idt1은 3연속 블록의 구성원이다. 그래서 OR로 묶는다.DISTINCT**로 중복을 제거한다.ORDER BY t1.id.t1이 항상 첫째라고 가정) 블록의 가운데·끝 행이 누락된다.people >= 100 조건을 t1에만 걸고 t2, t3에 빠뜨리면 100 미만인 이웃이 섞여 오답.id가 실제로 연속이 아닐 수 있다는 점(결번). 이 문제 데이터에선 id가 날짜와 함께 1씩 증가한다고 가정하지만, 일반적으로는 날짜 기준 연속 판정을 별도로 고려해야 한다.people >= 100인 행에 순번을 매기고 id - ROW_NUMBER()가 같은 그룹(gaps-and-islands)을 찾아 COUNT(*) >= 3인 그룹만 남기는 방법이 더 확장성 있다(STEP 8).링크: https://leetcode.cn/problems/consecutive-numbers/
학습 포인트: 셀프 조인으로 "연속된 3개 행이 같은 값"인지 탐지.
Logs(id, num) — id는 자동 증가(연속)같은 숫자가 적어도 3번 연속 등장한 num을 중복 없이 구한다.
n, n+1, n+2인 세 행을 뜻한다. 셀프 조인으로 이 세 행을 한 줄에 나란히 붙인다.l1.id = l2.id - 1은 l2.id = l1.id + 1, 즉 l2가 l1 바로 다음. l2.id = l3.id - 1도 마찬가지로 l3이 그다음.num이 모두 같으면(l1.num = l2.num = l3.num) 그 숫자가 3연속 등장한 것.DISTINCT.id = id ± 1로 연속 행 결합.l1.id = l2.id - 1의 방향(부호)을 헷갈리면 엉뚱한 이웃을 붙인다. l2.id - 1 = l1.id → l2가 뒤 행임을 명확히.id가 연속이 아닐 수 있는 데이터라면 이 방식이 깨진다. 이 문제는 id 연속을 가정.LEAD(num, 1), LEAD(num, 2)로 뒤 두 행을 당겨와 세 값이 같은지 비교하는 방식도 간결하다(STEP 8).링크: https://leetcode.cn/problems/employee-bonus/
학습 포인트: LEFT JOIN 후 NULL과 값 조건을 함께 다루는 함정.
Employee(empId, name, supervisor, salary)Bonus(empId, bonus) — 모든 직원이 보너스 행을 갖지는 않음보너스가 1000 미만인 직원의 name과 bonus를 구한다. 보너스 기록이 아예 없는(NULL) 직원도 포함한다.
LEFT JOIN.b.bonus가 NULL이 된다.NULL < 1000은 참이 아니라 UNKNOWN이다. 따라서 WHERE b.bonus < 1000만 쓰면 보너스 없는 직원이 탈락한다.OR b.bonus IS NULL을 명시해 NULL 직원을 명시적으로 포함시킨다.NULL < 1000 = UNKNOWN(참 아님) → WHERE에서 제외됨.WHERE b.bonus < 1000만 작성 → 보너스 없는 직원 누락(가장 흔한 오답).COALESCE(b.bonus, 0) < 1000처럼 NULL을 0으로 치환해 비교하는 것도 가능한 대안이지만, 출력 bonus는 여전히 NULL로 나와야 함에 유의.WHERE COALESCE(b.bonus, 0) < 1000 — NULL을 0으로 간주해 한 조건으로 처리.링크: https://leetcode.cn/problems/investments-in-2016/
학습 포인트: 두 개의 서브쿼리 조건 — 값 중복 필터와 좌표 유일 필터.
Insurance(pid, tiv_2015, tiv_2016, lat, lon) — pid가 기본키다음 두 조건을 모두 만족하는 폴리시들의 tiv_2016 합을 소수 둘째 자리로 구한다.
tiv_2015 값이 다른 폴리시와 겹친다(같은 tiv_2015가 둘 이상).(lat, lon) 좌표가 유일하다(다른 어떤 폴리시와도 위치가 같지 않음).tiv_2015 중복): tiv_2015로 묶어 COUNT(*) > 1인 값들의 집합을 구하고, 본문에서 그 값에 해당하는 폴리시만 남긴다.(lat, lon) 유일): 좌표로 묶어 COUNT(*) = 1인 좌표들의 집합을 구하고, 튜플 (lat, lon) IN (...)으로 매칭. 유일한 위치의 폴리시만 통과.AND로 결합한 뒤 tiv_2016을 합산하고 ROUND(..., 2).COUNT(*) > 1(중복), COUNT(*) = 1(유일).(lat, lon)을 한 쌍으로 비교.lat IN (...) AND lon IN (...)처럼 컬럼별로 나눠 쓰면 서로 다른 폴리시의 lat/lon이 교차 매칭되어 오답. 반드시 (lat, lon) 튜플로 함께 비교.ROUND를 빼면 형식이 어긋난다.링크: https://leetcode.cn/problems/reported-posts-ii/
학습 포인트: 날짜별 비율을 구한 뒤 평균 — DISTINCT와 계산 순서 주의.
Actions(user_id, post_id, action_date, action, extra) — action은 'view', 'like', 'reaction', 'report', 'comment' 등Removals(post_id, remove_date) — 스팸으로 제거된 게시글extra = 'spam'으로 신고된 게시글 중 실제 제거된(즉 Removals에 있는) 비율을 날짜별로 구한 다음, 그 일별 비율들의 평균을 백분율(소수 둘째 자리)로 낸다.
a.extra = 'spam' AND a.action = 'report'.Removals를 LEFT JOIN. 제거된 게시글은 r.post_id가 채워지고, 제거 안 됐으면 NULL.COUNT(DISTINCT a.post_id).COUNT(DISTINCT r.post_id)(NULL은 COUNT에서 제외됨).DISTINCT가 필수.* 100.0으로 실수 나눗셈을 유도해 백분율을 만든다.AVG(daily_percent) — 날짜별 비율의 평균을 구하고 ROUND(..., 2). 전체를 한꺼번에 나누는 게 아니라 "일별 비율의 평균"이라는 정의를 지켜야 한다.LEFT JOIN 후 매칭 안 된 r.post_id(NULL)는 분자에서 자동 제외.DISTINCT를 빼면 여러 명이 같은 글을 신고했을 때 분모·분자가 부풀려져 오답.100 / count처럼 정수끼리 나누면 소수가 잘린다. 100.0을 곱해 실수 연산 유도.링크: https://leetcode.cn/problems/market-analysis-i/
학습 포인트: LEFT JOIN + 조건부 집계로 특정 연도 건수 세기.
Users(user_id, join_date, favorite_brand)Orders(order_id, order_date, item_id, buyer_id, seller_id)Items(item_id, item_brand)모든 사용자에 대해 user_id, join_date, 그리고 2019년에 구매한 주문 건수(orders_in_2019)를 구한다. 구매가 없으면 0.
LEFT JOIN으로 모든 사용자를 유지한다.ON 절에 두는 것이다. AND YEAR(o.order_date) = 2019를 조인 조건에 넣으면, 2019년 주문만 매칭되고 나머지는 붙지 않아 NULL이 된다.COUNT(o.order_id)는 NULL을 세지 않으므로, 매칭된 2019년 주문이 없는 사용자는 자연스럽게 0이 된다.Items 테이블은 이 문제에서 필요 없다.ON에 두면 왼쪽 행은 보존되고 매칭만 제한된다. WHERE에 두면 미매칭 행(NULL)이 걸러져 LEFT JOIN이 INNER JOIN처럼 변한다.LEFT JOIN으로 구현.YEAR(o.order_date) = 2019를 WHERE에 두면, 2019년 주문이 없는 사용자가 통째로 사라져 0 행이 누락된다. 반드시 ON에 둔다.COUNT(*)를 쓰면 미매칭 시에도 NULL 행 1개를 세어 1이 나온다. 반드시 COUNT(o.order_id)처럼 조인 대상 컬럼을 센다.Orders를 일반 LEFT JOIN(연도 조건 없이)하고 SELECT에서 연도를 판별한다.