집계의 핵심. WHERE(행 필터)와 HAVING(그룹 필터)의 차이, 조건부 집계(SUM(CASE))를 익힌다.
링크: https://leetcode.cn/problems/duplicate-emails/
학습 포인트: GROUP BY로 묶고 HAVING COUNT(*) > 1로 중복 그룹만 필터한다.
Person 테이블
| 컬럼 | 타입 |
|---|---|
| id | int (PK) |
| varchar |
email은 NULL이 아니며 모두 소문자다.GROUP BY email로 이메일별 그룹을 만든다.COUNT(*)로 센다. 그룹의 크기가 곧 그 이메일의 등장 횟수다.COUNT(*) > 1. 이 조건은 개별 행이 아니라 그룹 전체에 대한 조건이므로 WHERE가 아니라 HAVING에 쓴다.WHERE는 그룹화 이전 개별 행에, HAVING은 그룹화 이후에 적용된다.COUNT(*)는 그룹 내 행 수를 센다.WHERE COUNT(*) > 1은 오류다. WHERE는 그룹화 전에 실행되므로 집계 함수를 쓸 수 없다.SELECT email, COUNT(*)처럼 개수를 함께 출력하면 요구 컬럼(email)과 달라져 오답이 될 수 있다.SELECT DISTINCT a.email FROM Person a JOIN Person b ON a.email=b.email AND a.id<>b.id; 하지만 GROUP BY + HAVING이 훨씬 간결하다.링크: https://leetcode.cn/problems/customer-placing-the-largest-number-of-orders/
학습 포인트: GROUP BY 후 ORDER BY COUNT(*) DESC LIMIT 1로 최대 그룹을 뽑는다.
Orders 테이블
| 컬럼 | 타입 |
|---|---|
| order_number | int (PK) |
| customer_number | int |
customer_number를 구하라.GROUP BY customer_number로 고객 단위 그룹을 만든다.COUNT(*)다.ORDER BY COUNT(*) DESC) 후 맨 위 한 건(LIMIT 1)을 취한다.SELECT 절에 COUNT(*)를 안 써도 ORDER BY에서는 집계 함수를 사용할 수 있다.ORDER BY 절에서도 집계 함수를 정렬 기준으로 쓸 수 있다.LIMIT n은 정렬된 결과에서 상위 n행만 반환한다.LIMIT 1은 임의로 한 명만 남긴다. 동점을 모두 뽑으려면 HAVING COUNT(*) = (SELECT MAX(cnt) ...) 또는 윈도 함수 RANK()를 써야 한다.링크: https://leetcode.cn/problems/actors-and-directors-who-cooperated-at-least-three-times/
학습 포인트: 여러 컬럼으로 그룹화(GROUP BY a, b)한 뒤 HAVING으로 그룹 크기를 거른다.
ActorDirector 테이블
| 컬럼 | 타입 |
|---|---|
| actor_id | int |
| director_id | int |
| timestamp | int (PK) |
GROUP BY actor_id, director_id. 두 값이 모두 같아야 한 그룹이다.COUNT(*))다. timestamp가 PK라 각 협업 기록이 별개 행으로 존재한다.HAVING COUNT(*) >= 3.HAVING은 >=, <, 범위 조건 등 집계값 비교에 자유롭게 쓸 수 있다.GROUP BY actor_id만 하면 감독이 뒤섞여 잘못 집계된다. 조합 단위 문제에서는 관련 컬럼을 모두 그룹키에 넣어야 한다.링크: https://leetcode.cn/problems/find-followers-count/
학습 포인트: 그룹별 COUNT를 컬럼으로 출력하고 ORDER BY로 정렬한다.
Followers 테이블
| 컬럼 | 타입 |
|---|---|
| user_id | int |
| follower_id | int |
user_id 오름차순으로 정렬하라.user_id, followers_countGROUP BY user_id.follower_id 개수다. (user_id, follower_id)가 PK라 중복이 없으니 COUNT(follower_id)와 COUNT(*) 결과가 같다.ORDER BY user_id로 오름차순 정렬한다.AS followers_count로 맞춘다.AS)으로 요구된 이름과 정확히 일치시켜야 한다.COUNT(col)은 그 컬럼이 NULL이 아닌 행만 센다. COUNT(*)는 NULL 포함 전체 행을 센다.ORDER BY를 넣는다.링크: https://leetcode.cn/problems/queries-quality-and-percentage/
학습 포인트: 비율 집계 AVG(rating/position)와 조건부 비율 AVG(조건)*100, ROUND(x, 2).
Queries 테이블
| 컬럼 | 타입 |
|---|---|
| query_name | varchar |
| result | varchar |
| position | int |
| rating | int |
rating/position 값들의 평균.rating < 3인 쿼리의 비율(백분율).query_name별로 출력.query_name별 지표이므로 GROUP BY query_name.rating/position을 계산한 값들의 평균"이다. 따라서 AVG(rating / position) — 나눗셈을 먼저 하고 그 결과를 평균낸다. (AVG(rating)/AVG(position)과는 다른 값이므로 순서가 중요하다.)rating < 3이 참이면 1, 거짓이면 0을 반환한다는 점이다. 따라서 AVG(rating < 3)은 "1의 비율" = 조건을 만족하는 행의 비율(0~1)이 된다. 여기에 *100을 곱해 백분율로 만든다.ROUND(..., 2)로 소수 2자리 반올림.AVG(불리언 조건)은 참인 행의 비율을 준다. SUM(조건)/COUNT(*)와 동일하다.AVG(a/b) ≠ AVG(a)/AVG(b). 정의된 계산 순서를 그대로 옮겨야 한다.ROUND(값, 자릿수)로 반올림.*100을 빠뜨려 0~1 사이 값을 내는 실수.SUM(CASE WHEN rating<3 THEN 1 ELSE 0 END)/COUNT(*)처럼 정수 나눗셈을 쓰면 결과가 0으로 잘릴 수 있다. AVG 방식이 안전하다.AVG(rating < 3) * 100이 더 짧지만 의미는 같다.링크: https://leetcode.cn/problems/monthly-transactions-i/
학습 포인트: DATE_FORMAT으로 월 추출 + SUM(CASE ...) 조건부 집계로 피벗(가로 집계).
Transactions 테이블
| 컬럼 | 타입 |
|---|---|
| id | int (PK) |
| country | varchar |
| state | enum('approved','declined') |
| amount | int |
| trans_date | date |
month : YYYY-MM 형식trans_count : 전체 거래 수approved_count : 승인된 거래 수trans_total_amount : 전체 거래 금액 합approved_total_amount : 승인된 거래 금액 합DATE_FORMAT(trans_date, '%Y-%m') → '2019-01' 형태.country로 그룹화한다. MySQL은 SELECT의 별칭 month를 GROUP BY에서 참조할 수 있다.COUNT(*), SUM(amount)는 그룹 전체 대상.SUM(state = 'approved') : 불리언이 1/0이므로 승인 건수가 된다.SUM(CASE WHEN state = 'approved' THEN amount ELSE 0 END) : 승인이면 금액을, 아니면 0을 더해 승인 금액 합만 골라낸다.GROUP BY로 전체·승인 지표를 한 행에 나란히(피벗) 배치할 수 있다.SUM(CASE WHEN 조건 THEN 값 ELSE 0 END)는 "조건을 만족하는 행만" 합산하는 표준 관용구다.WHERE state='approved'로 걸러버리면 declined 행이 사라져 trans_count(전체)를 계산할 수 없다. 그래서 필터를 WHERE가 아닌 집계 함수 안에 둔다.DATE_FORMAT(date, '%Y-%m')으로 연-월 문자열을 만든다.WHERE로 거르면 전체 지표를 잃는다. 조건부 집계로 해결해야 한다.CASE에서 ELSE 0을 빼면 NULL이 되고, SUM은 NULL을 무시하므로 건수 집계엔 문제없지만 습관적으로 ELSE 0을 명시하는 게 안전하다.SUM(state = 'approved') 대신 COUNT(CASE WHEN state='approved' THEN 1 END)도 승인 건수를 준다 (COUNT는 NULL을 세지 않음).링크: https://leetcode.cn/problems/immediate-food-delivery-ii/
학습 포인트: 서브쿼리로 고객별 "첫 주문"을 뽑고, 그중 즉시배달 비율을 AVG(조건)으로 계산.
Delivery 테이블
| 컬럼 | 타입 |
|---|---|
| delivery_id | int (PK) |
| customer_id | int |
| order_date | date |
| customer_pref_delivery_date | date |
order_date)만 대상으로 한다.order_date = customer_pref_delivery_date인 경우.GROUP BY customer_id, MIN(order_date)로 (고객, 첫주문일) 쌍을 만든다.WHERE (customer_id, order_date) IN (...). MySQL은 이런 튜플(다중 컬럼) IN을 지원한다.AVG(order_date = customer_pref_delivery_date). 불리언이 1/0이므로 평균이 곧 즉시배달 고객 비율(0~1)이다.*100으로 백분율, ROUND(..., 2)로 2자리.WHERE (a, b) IN (SELECT a, MIN(b) ...)으로 그룹별 대표 행을 정확히 매칭한다.MIN(order_date). 문제는 한 고객이 같은 날 두 번 첫 주문하는 경우가 없다고 가정한다.AVG(조건)을 취하면 자동으로 이 비율이 된다.MIN(order_date)만 서브쿼리로 뽑아 WHERE order_date IN (...)처럼 쓰면 고객 매칭이 빠져 엉뚱한 행이 섞인다. 반드시 (customer_id, order_date) 쌍으로 매칭한다.링크: https://leetcode.cn/problems/game-play-analysis-iv/
학습 포인트: 고객별 첫 로그인일 + DATE_ADD(첫날, 1) 재접속 여부. 분모는 전체 플레이어 수.
Activity 테이블
| 컬럼 | 타입 |
|---|---|
| player_id | int |
| device_id | int |
| event_date | date |
| games_played | int |
(SELECT COUNT(DISTINCT player_id) FROM Activity). 스칼라 서브쿼리로 고정값을 만든다.GROUP BY player_id, MIN(event_date).a가 "첫날 다음날 접속"이려면, a.event_date에서 하루를 뺀 날(DATE_SUB(a.event_date, INTERVAL 1 DAY))이 그 플레이어의 첫 로그인일과 같아야 한다. 그래서 (player_id, event_date - 1) IN (player_id, MIN(event_date))로 매칭한다.
+1을 해도 된다: DATE_ADD(MIN(event_date), 1). 여기서는 바깥 행 기준으로 -1을 적용했다.COUNT(DISTINCT a.player_id)가 분자다.DATE_ADD(d, INTERVAL 1 DAY) / DATE_SUB(d, INTERVAL 1 DAY)로 연속일을 비교한다.event_date = MIN(event_date) + 1을 셀프 조인 없이 한 테이블에서 처리하려다 플레이어 매칭을 놓치는 실수. 반드시 player_id를 함께 매칭한다.DISTINCT를 빼면 같은 플레이어가 중복 계산될 수 있다(여기선 PK 때문에 문제없지만 습관적으로 DISTINCT 권장).링크: https://leetcode.cn/problems/capital-gain-loss/
학습 포인트: SUM(CASE WHEN operation='Buy' THEN -price ELSE price END) — 부호를 조건부로 바꿔 합산.
Stocks 테이블
| 컬럼 | 타입 |
|---|---|
| stock_name | varchar |
| operation | enum('Buy','Sell') |
| operation_day | int (PK와 함께) |
| price | int |
GROUP BY stock_name.CASE로 표현한다:
operation = 'Buy' → -price (돈이 나감)'Sell') → price (돈이 들어옴)SUM으로 그룹 내 전부 합치면 (매도 합 − 매수 합) = 순손익이 된다.SUM에 부호 있는 값을 넣는 것이 핵심 — 매수/매도를 따로 집계해 빼는 것보다 간결하다.SUM(CASE WHEN ... THEN -x ELSE x END)으로 방향이 다른 값을 한 번에 합산한다.WHERE로 분리해 두 쿼리로 만든 뒤 빼려는 접근은 불필요하게 복잡하다. CASE 한 방으로 끝난다.링크: https://leetcode.cn/problems/customers-who-bought-products-a-and-b-but-not-c/
학습 포인트: HAVING SUM(조건) > 0 (존재)와 SUM(조건) = 0 (부재)로 그룹 조건을 조합한다.
Customers 테이블
| 컬럼 | 타입 |
|---|---|
| customer_id | int (PK) |
| customer_name | varchar |
Orders 테이블
| 컬럼 | 타입 |
|---|---|
| order_id | int (PK) |
| customer_id | int |
| product_name | varchar |
customer_id, customer_name (customer_id 기준 정렬)GROUP BY c.customer_id, c.customer_name.SUM(o.product_name = 'A')는 A인 행마다 1을 더하므로, A를 산 적이 있으면 값이 1 이상(> 0), 없으면 0이다.SUM(product_name='A') > 0, "C를 안 샀다" = SUM(product_name='C') = 0.AND로 묶어 HAVING에 둔다. 개별 행이 아닌 그룹 전체 성질이므로 HAVING이 맞다.Orders만으로도 풀리지만 이름 출력을 위해 Customers와 조인한다. GROUP BY에 customer_name도 포함해 SELECT와 정합성을 맞춘다.SUM(조건) > 0은 "그 조건을 만족하는 행이 하나라도 있다", SUM(조건) = 0은 "하나도 없다"를 뜻한다.MAX(product_name = 'A') = 1, MIN(...)으로도 존재 여부를 표현할 수 있다.HAVING은 여러 그룹 조건을 AND/OR로 조합할 수 있다.WHERE product_name IN ('A','B')로 먼저 걸러버리면 C 구매 행이 사라져 "C를 안 샀다"를 판정할 수 없다. 필터를 SUM(조건) 안으로 넣어 모든 행을 보존해야 한다.only_full_group_by 모드에서는 SELECT의 customer_name이 GROUP BY에 없으면 오류다. 그룹키에 포함시킨다.MAX 버전: HAVING MAX(product_name='A')=1 AND MAX(product_name='B')=1 AND MAX(product_name='C')=0 — "존재=1, 부재=0"을 더 직관적으로 표현한다.