[SQL/실전] 평점 양극화 분석: 집계 함수와 HAVING 절의 제약 사항
3642. Find Books with Polarized Opinions
https://leetcode.com/problems/find-books-with-polarized-opinions/
1. Problem
독자들 사이에서 호불호가 극명하게 갈리는 '양극화된 도서'를 찾아야 한다.
- 조건 1: 최소 5개 이상의 독서 세션이 존재해야 한다.
- 조건 2: 4점 이상(고평점)과 2점 이하(저평점)가 각각 최소 하나 이상 존재해야 한다.
- 조건 3: 전체 세션 중 극단적 평점(≤2 또는 ≥4)의 비율인 '양극화 점수'가 0.6 이상이어야 한다.
- 결과: 양극화 점수 내림차순, 제목 내림차순으로 정렬한다.
2. Solution: 서브쿼리를 활용한 단계적 필터링
가장 안전한 방법은 서브쿼리(또는 CTE)에서 필요한 집계값들을 먼저 구한 뒤, 바깥쪽 쿼리에서 최종 비율 조건을 필터링하는 것이다.
3. Takeaway: 왜 오답 쿼리는 작동하지 않는가? (객관적 분석)
- 별칭(Alias) 참조의 한계:
- 오답 사유: HAVING 절에서 polarization_score >= 0.6을 직접 사용하였다.
- 원인: SQL의 표준 실행 순서상 SELECT 절에서 정의한 별칭(polarization_score)은 같은 단계의 HAVING 절에서 인식되지 않는 경우가 많다(DBMS마다 차이가 있으나 MySQL에서도 권장되지 않는다).
- 해결: 유진은 이를 서브쿼리로 감싸거나 HAVING 절에 수식을 직접 기입하여 해결하였다.
- SUM(CASE WHEN...)의 정교함:
- 단순히 WHERE 절에서 평점을 필터링하면 전체 세션 수(COUNT(*)) 자체가 줄어들어 정확한 비율 계산이 불가능해진다.
- 유진은 SUM(CASE WHEN...)을 사용하여 분모는 유지한 채 분자만 선택적으로 카운트하는 정석적인 방식을 취하였다.
💡 작성 팁
- 시각적 가이드: 전체 평점 리스트 중에서 양 끝(1, 2점과 4, 5점)에 위치한 데이터들만 빨간색으로 표시하고, 중간(3점)은 회색으로 둔 그림을 상상해 보라. 전체 중 빨간색의 비중이 60%를 넘는지 확인하는 과정이 곧 양극화 분석이다.
- 실무적 제언: "이 로직은 넷플릭스나 아마존 같은 플랫폼에서 '논란의 중심에 선 콘텐츠'를 파악할 때 유용하다. 평균 점수는 평범해 보일지라도 실제로는 취향이 극명히 갈리는 데이터를 찾아내 마케팅 전략을 세우는 데 활용된다"는 인사이트를 덧붙인다.
정답 쿼리
### 가장 성능이 뛰어난 정답 쿼리 ###
SELECT
b.book_id,
b.title,
b.author,
b.genre,
b.pages,
MAX(rs2.session_rating) - MIN(rs2.session_rating) AS rating_spread,
-- (극단적 평점의 수 / 전체 세션 수) 계산 및 반올림
ROUND(
SUM(CASE WHEN rs2.session_rating >= 4 OR rs2.session_rating <= 2 THEN 1 ELSE 0 END)
/ COUNT(*), 2
) AS polarization_score
FROM reading_sessions rs2
JOIN books b ON rs2.book_id = b.book_id
GROUP BY b.book_id, b.title, b.author, b.genre, b.pages
HAVING
COUNT(*) >= 5 -- 1. 최소 5개의 독서 세션
AND MIN(session_rating) <= 2 -- 2. 최소 하나 이상의 2점 이하 평점
AND MAX(session_rating) >= 4 -- 3. 최소 하나 이상의 4점 이상 평점
AND polarization_score >= 0.6 -- 4. 양극화 점수 0.6 이상
ORDER BY polarization_score DESC, b.title DESC;
쿼리 핵심 포인트 분석
(1) WHERE vs HAVING의 적절한 사용
사용자님은 원래 WHERE 절에 서브쿼리를 넣어 양극화 조건을 필터링하려 했다. 하지만 MAX, MIN, COUNT와 같은 집계 데이터를 기준으로 필터링할 때는 HAVING 절을 사용하는 것이 훨씬 효율적이고 가독성이 좋다.
(2) 분자 계산: SUM(CASE WHEN...) 활용
오답 쿼리에서는 COUNT(*)를 분자로 쓰려 했으나, 이는 '극단적 평점'만 세는 것이 아니라 전체 행을 세게 된다. 따라서 SUM(CASE WHEN 평점 조건 THEN 1 ELSE 0 END) 방식을 사용하여 조건에 맞는 행만 선택적으로 합산해야 한다.
(3) 분모 계산: 집계 함수 내 연산
상관 서브쿼리를 SELECT 절에 넣는 대신, GROUP BY를 이미 사용 중이므로 단순히 **COUNT(*)**를 분모로 쓰면 해당 책의 전체 세션 수를 가져올 수 있다. 이는 성능 면에서 상관 서브쿼리보다 유리하다
### 나의 오답 쿼리를 정정한 정답 쿼리 ###
SELECT
t.book_id,
t.title,
t.author,
t.genre,
t.pages,
t.rating_spread,
ROUND(t.extreme_count / t.total_count, 2) AS polarization_score
FROM (
SELECT
rs2.book_id,
b.title,
b.author,
b.genre,
b.pages,
MAX(rs2.session_rating) - MIN(rs2.session_rating) AS rating_spread,
-- 1. 분자: 조건에 맞는 행만 카운트 (WHERE 절 대신 SUM CASE 사용이 안전)
SUM(CASE WHEN rs2.session_rating <= 2 OR rs2.session_rating >= 4 THEN 1 ELSE 0 END) AS extreme_count,
-- 2. 분모: 해당 책의 전체 행 개수
COUNT(*) AS total_count
FROM reading_sessions rs2
JOIN books b ON rs2.book_id = b.book_id
GROUP BY rs2.book_id, b.title, b.author, b.genre, b.pages
HAVING
COUNT(*) >= 5
AND MIN(rs2.session_rating) <= 2
AND MAX(rs2.session_rating) >= 4
) t
WHERE (t.extreme_count / t.total_count) >= 0.6
ORDER BY polarization_score DESC, t.title DESC;
오답 쿼리
나의 오답쿼리는 논리적인 부분에서도 부족한 점이 있었지만, CTE, 서브쿼리, JOIN 를 모두 사용하면서 성능 저하를 초래했다. 복잡한 조건이 많을수록 WHERE에서 서브쿼리를 남발하기보다 GROUP BY와 HAVING의 조합으로 단단하게 묶는 것이 정답이다.
### 오답 쿼리 ###
# WITH절 1번 사용
WITH rs3 AS (
SELECT rs2.book_id,
MIN(rs2.session_rating) AS lowest_rating,
MAX(rs2.session_rating) AS highest_rating,
MAX(rs2.session_rating) - MIN(rs2.session_rating) AS rating_spread,
# 서브쿼리 1번 사용
COUNT(*)/(SELECT COUNT(*) FROM reading_sessions rs1 WHERE rs1.book_id = rs2.book_id GROUP BY rs1.book_id) AS polarization_score
FROM reading_sessions rs2
WHERE rs2.session_rating <= 2 OR rs2.session_rating >= 4
GROUP BY rs2.book_id
)
SELECT rs3.book_id, b.title, b.author, b.genre, b.pages, rs3.rating_spread, rs3.polarization_score
FROM rs3
# 조인 1번 사용
JOIN books b on rs3.book_id = b.book_id
WHERE rs3.lowest_rating <=2 AND rs3.highest_rating >= 4
ORDER BY rs3.polarization_score DESC, b.title DESC;