EXISTS와 안티 패턴
사용 테이블
customers — 고객 (등급, 포인트, 가입채널)
orders — 주문 (상태, 금액, 일시)
order_items — 주문 상세 (수량, 단가)
products — 상품 (이름, 가격, 재고, 브랜드)
reviews — 리뷰 (평점, 내용)
wishlists — 위시리스트 (고객-상품)
complaints — 고객 불만 (유형, 우선순위)
categories — 카테고리 (부모-자식 계층)
payments — 결제 (방법, 금액, 상태)
학습 범위
EXISTS, NOT EXISTS, Correlated Subquery, Anti-join Pattern, Universal Quantification, Double Negation
1. 2024년에 주문을 한 적이 있는 고객만 조회하세요.
고객 ID, 이름, 등급, 가입일을 표시합니다. EXISTS를 사용하세요.
힌트 1: WHERE EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id AND ...)
서브쿼리에서 외부 쿼리의 c.id를 참조하는 것이 상관 서브쿼리입니다.
정답
SELECT
c.id,
c.name,
c.grade,
c.created_at AS signup_date
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
AND o.ordered_at LIKE '2024%'
AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
)
ORDER BY c.id
LIMIT 20;
실행 결과 (총 20행 중 상위 7행)
| id | name | grade | signup_date |
|---|---|---|---|
| 2 | 김경수 | GOLD | 2016-08-17 12:29:34 |
| 3 | 김민재 | VIP | 2016-02-11 19:59:38 |
| 4 | 진정자 | GOLD | 2016-09-18 15:29:45 |
| 5 | 이정수 | SILVER | 2016-02-28 11:34:16 |
| 8 | 성민석 | SILVER | 2016-09-24 06:49:22 |
| 10 | 박지훈 | GOLD | 2016-12-20 04:06:43 |
| 12 | 장준서 | GOLD | 2016-12-30 06:48:08 |
2. 한 번도 주문한 적이 없는 고객을 찾으세요.
가입만 하고 주문 이력이 전혀 없는 고객입니다. NOT EXISTS를 사용하세요.
힌트 1: WHERE NOT EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id)
이때 취소/반품 주문도 "주문한 적 있음"으로 간주합니다 (상태 필터 없음).
정답
SELECT
c.id,
c.name,
c.grade,
c.created_at AS signup_date,
ROUND(JULIANDAY('2025-12-31') - JULIANDAY(c.created_at), 0) AS days_since_signup
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
)
ORDER BY c.created_at
LIMIT 20;
실행 결과 (총 20행 중 상위 7행)
| id | name | grade | signup_date | days_since_signup |
|---|---|---|---|---|
| 84 | 양영진 | BRONZE | 2016-01-03 19:49:46 | 3,649.00 |
| 38 | 박준영 | BRONZE | 2016-01-15 19:21:20 | 3,637.00 |
| 9 | 주경희 | BRONZE | 2016-01-26 09:42:20 | 3,627.00 |
| 69 | 이경수 | BRONZE | 2016-02-03 03:40:29 | 3,619.00 |
| 25 | 윤준영 | BRONZE | 2016-02-03 04:18:52 | 3,619.00 |
| 32 | 박수빈 | BRONZE | 2016-02-09 18:54:54 | 3,612.00 |
| 7 | 김명자 | BRONZE | 2016-02-17 13:41:08 | 3,604.00 |
3. 리뷰를 남기지 않은 구매 확인 고객을 찾으세요.
주문 상태가 'confirmed'인 주문이 있지만 리뷰를 단 한 건도 작성하지 않은 고객입니다.
힌트 1: 조건 2개를 조합합니다:
EXISTS (... orders WHERE status = 'confirmed') AND
NOT EXISTS (... reviews WHERE customer_id = c.id).
정답
SELECT
c.id,
c.name,
c.grade,
COUNT(o.id) AS confirmed_orders
FROM customers AS c
INNER JOIN orders AS o
ON c.id = o.customer_id
AND o.status = 'confirmed'
WHERE NOT EXISTS (
SELECT 1
FROM reviews AS r
WHERE r.customer_id = c.id
)
GROUP BY c.id, c.name, c.grade
ORDER BY confirmed_orders DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| id | name | grade | confirmed_orders |
|---|---|---|---|
| 494 | 이지우 | GOLD | 20 |
| 124 | 김시우 | BRONZE | 13 |
| 1207 | 김지민 | SILVER | 12 |
| 1620 | 장정순 | BRONZE | 12 |
| 2164 | 백중수 | SILVER | 12 |
| 2236 | 문정식 | BRONZE | 12 |
| 2487 | 오수민 | BRONZE | 12 |
4. 위시리스트에 담았지만 아직 구매하지 않은 상품-고객 조합을 찾으세요.
위시리스트의 is_purchased = 0인 항목 중, 해당 고객이 해당 상품을 실제로 주문한 적도 없는 경우입니다.
힌트 1: wishlists를 기준으로, NOT EXISTS로 order_items와 orders를 결합한 서브쿼리를 만듭니다.
서브쿼리 조건: 같은 customer_id와 같은 product_id.
정답
SELECT
w.customer_id,
c.name AS customer_name,
w.product_id,
p.name AS product_name,
p.price,
w.created_at AS wishlisted_at
FROM wishlists AS w
INNER JOIN customers AS c ON w.customer_id = c.id
INNER JOIN products AS p ON w.product_id = p.id
WHERE w.is_purchased = 0
AND NOT EXISTS (
SELECT 1
FROM order_items AS oi
INNER JOIN orders AS o ON oi.order_id = o.id
WHERE o.customer_id = w.customer_id
AND oi.product_id = w.product_id
AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
)
ORDER BY w.created_at DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| customer_id | customer_name | product_id | product_name | price | wishlisted_at |
|---|---|---|---|---|---|
| 4853 | 박성호 | 92 | 주연 리오나인 미니PC | 1,194,000.00 | 2025-12-30 19:11:10 |
| 5229 | 김영숙 | 223 | 삼성 갤럭시북4 360 블랙 | 1,388,600.00 | 2025-12-30 17:42:08 |
| 4675 | 백지후 | 271 | TP-Link TL-SG108 | 108,500.00 | 2025-12-30 11:47:20 |
| 4940 | 이영수 | 191 | Seagate IronWolf 4TB 블랙 | 545,400.00 | 2025-12-30 10:41:18 |
| 3584 | 이정순 | 194 | SK하이닉스 Platinum P41 2TB 블랙 | 237,500.00 | 2025-12-30 10:16:54 |
| 4546 | 문하은 | 239 | TeamGroup T-Force Vulcan DDR5 32GB 52... | 139,800.00 | 2025-12-30 09:25:54 |
| 4796 | 김현주 | 171 | APC Back-UPS Pro Gaming BGM1500B 블랙 | 516,300.00 | 2025-12-30 06:38:37 |
5. CS 문의가 접수된 적이 없는 고객 중, 주문 금액 상위 10명을 찾으세요.
클레임 없이 꾸준히 구매하는 "우량 고객"을 식별합니다.
힌트 1: NOT EXISTS (SELECT 1 FROM complaints WHERE customer_id = c.id)로 문의 이력이 없는 고객을 필터링하고,
SUM(total_amount)으로 총 구매 금액 상위 10명을 추출합니다.
정답
SELECT
c.id,
c.name,
c.grade,
COUNT(o.id) AS order_count,
ROUND(SUM(o.total_amount), 0) AS total_spent
FROM customers AS c
INNER JOIN orders AS o ON c.id = o.customer_id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
AND NOT EXISTS (
SELECT 1
FROM complaints AS cp
WHERE cp.customer_id = c.id
)
GROUP BY c.id, c.name, c.grade
ORDER BY total_spent DESC
LIMIT 10;
실행 결과 (총 10행 중 상위 7행)
| id | name | grade | order_count | total_spent |
|---|---|---|---|---|
| 514 | 오현준 | BRONZE | 5 | 52,141,700.00 |
| 3000 | 허도윤 | GOLD | 47 | 51,674,714.00 |
| 4065 | 박영자 | VIP | 12 | 43,306,619.00 |
| 41 | 노상훈 | BRONZE | 34 | 42,490,481.00 |
| 44 | 김하은 | BRONZE | 45 | 40,153,649.00 |
| 4136 | 김영미 | VIP | 4 | 39,557,863.00 |
| 1131 | 이경자 | SILVER | 36 | 39,097,438.00 |
6. 3개 이상의 서로 다른 카테고리에서 상품을 구매한 고객을 찾으세요.
EXISTS 내부에서 집계를 사용합니다.
힌트 1: EXISTS 안에서 GROUP BY customer_id HAVING COUNT(DISTINCT category_id) >= 3을 사용합니다.
또는 상관 서브쿼리로 카테고리 수를 세는 방법도 있습니다.
정답
SELECT
c.id,
c.name,
c.grade
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM order_items AS oi
INNER JOIN orders AS o ON oi.order_id = o.id
INNER JOIN products AS p ON oi.product_id = p.id
WHERE o.customer_id = c.id
AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
GROUP BY o.customer_id
HAVING COUNT(DISTINCT p.category_id) >= 3
)
ORDER BY c.grade DESC, c.name
LIMIT 20;
실행 결과 (총 20행 중 상위 7행)
| id | name | grade |
|---|---|---|
| 3645 | 강경숙 | VIP |
| 162 | 강명자 | VIP |
| 824 | 강민석 | VIP |
| 1516 | 강민재 | VIP |
| 1613 | 강상철 | VIP |
| 3678 | 강순옥 | VIP |
| 2454 | 강옥순 | VIP |
7. 모든 결제가 정상 완료된 주문만 조회하세요.
한 주문에 여러 결제가 있을 수 있습니다. 실패(failed)나 환불(refunded)된 결제가 하나라도 없는 주문을 찾습니다.
힌트 1: "실패/환불이 없다" = NOT EXISTS (... payments WHERE status IN ('failed', 'refunded') AND order_id = o.id).
추가로 결제가 하나 이상 존재해야 합니다(EXISTS).
정답
SELECT
o.id,
o.order_number,
o.total_amount,
o.ordered_at,
o.status
FROM orders AS o
WHERE EXISTS (
SELECT 1
FROM payments AS p
WHERE p.order_id = o.id
AND p.status = 'completed'
)
AND NOT EXISTS (
SELECT 1
FROM payments AS p
WHERE p.order_id = o.id
AND p.status IN ('failed', 'refunded')
)
AND o.ordered_at LIKE '2024%'
ORDER BY o.ordered_at DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| id | order_number | total_amount | ordered_at | status |
|---|---|---|---|---|
| 31,230 | ORD-20241231-31230 | 506,700.00 | 2024-12-31 21:25:24 | confirmed |
| 31,229 | ORD-20241231-31229 | 425,600.00 | 2024-12-31 20:47:26 | confirmed |
| 31,228 | ORD-20241231-31228 | 548,900.00 | 2024-12-31 20:17:42 | confirmed |
| 31,223 | ORD-20241231-31223 | 531,300.00 | 2024-12-31 19:30:18 | confirmed |
| 31,226 | ORD-20241231-31226 | 500,100.00 | 2024-12-31 19:28:26 | confirmed |
| 31,238 | ORD-20241231-31238 | 658,500.00 | 2024-12-31 16:08:40 | confirmed |
| 31,236 | ORD-20241231-31236 | 144,100.00 | 2024-12-31 15:52:45 | confirmed |
8. 특정 상품(ID=1)과 함께 자주 구매되는 상품을 찾으세요 (동시 구매 분석).
상품 1을 포함한 주문에서, 상품 1 이외의 다른 상품 중 동시 구매 빈도가 높은 순으로 정렬합니다.
힌트 1: 외부 쿼리는 order_items에서 product_id != 1인 항목을 집계합니다.
EXISTS로 "해당 주문에 상품 1이 포함되어 있는지"를 확인합니다.
정답
SELECT
p.id AS product_id,
p.name AS product_name,
p.price,
COUNT(DISTINCT oi.order_id) AS co_purchase_count
FROM order_items AS oi
INNER JOIN products AS p ON oi.product_id = p.id
WHERE oi.product_id != 1
AND EXISTS (
SELECT 1
FROM order_items AS oi2
WHERE oi2.order_id = oi.order_id
AND oi2.product_id = 1
)
GROUP BY p.id, p.name, p.price
ORDER BY co_purchase_count DESC
LIMIT 10;
실행 결과 (총 10행 중 상위 7행)
| product_id | product_name | price | co_purchase_count |
|---|---|---|---|
| 45 | SteelSeries Aerox 5 Wireless 실버 | 100,000.00 | 33 |
| 70 | JBL Pebbles 2 블랙 | 101,500.00 | 31 |
| 9 | 소니 WH-CH720N 실버 | 445,700.00 | 30 |
| 28 | Keychron Q1 Pro 실버 | 238,000.00 | 26 |
| 8 | 로지텍 G715 화이트 | 131,500.00 | 25 |
| 34 | SteelSeries Prime Wireless 블랙 | 89,800.00 | 25 |
| 111 | 로지텍 G502 X PLUS | 97,500.00 | 24 |
9. "매월 빠짐없이 주문한 고객"을 찾으세요 (2024년 12개월).
2024년의 모든 12개월에 최소 1건의 주문이 있는 고객입니다.
힌트 1: NOT EXISTS와 재귀 CTE(또는 하드코딩된 월 리스트)를 조합합니다.
"모든 월에 주문이 있다" = "주문이 없는 월이 존재하지 않는다" (NOT EXISTS).
정답
WITH RECURSIVE months AS (
SELECT '2024-01' AS ym
UNION ALL
SELECT SUBSTR(DATE(ym || '-01', '+1 month'), 1, 7)
FROM months
WHERE ym < '2024-12'
)
SELECT
c.id,
c.name,
c.grade
FROM customers AS c
WHERE NOT EXISTS (
-- 주문이 없는 월이 하나라도 있으면 제외
SELECT 1
FROM months AS m
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
AND SUBSTR(o.ordered_at, 1, 7) = m.ym
AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
)
)
ORDER BY c.grade DESC, c.name;
실행 결과 (3행)
| id | name | grade |
|---|---|---|
| 3097 | 심승현 | VIP |
| 2516 | 이정숙 | VIP |
| 3775 | 황정자 | VIP |
10. 2024년에 주문은 했지만 2025년에는 주문하지 않은 "이탈 고객"을 찾으세요.
두 개의 EXISTS/NOT EXISTS 조건을 결합합니다.
힌트 1: EXISTS (... 2024년 주문) AND NOT EXISTS (... 2025년 주문).
이탈 고객의 2024년 마지막 주문일과 총 구매 금액도 표시하면 유용합니다.
정답
SELECT
c.id,
c.name,
c.grade,
MAX(o.ordered_at) AS last_order_date,
COUNT(o.id) AS orders_in_2024,
ROUND(SUM(o.total_amount), 0) AS spent_in_2024
FROM customers AS c
INNER JOIN orders AS o
ON c.id = o.customer_id
AND o.ordered_at LIKE '2024%'
AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o2
WHERE o2.customer_id = c.id
AND o2.ordered_at LIKE '2025%'
AND o2.status NOT IN ('cancelled', 'returned', 'return_requested')
)
GROUP BY c.id, c.name, c.grade
ORDER BY spent_in_2024 DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| id | name | grade | last_order_date | orders_in_2024 | spent_in_2024 |
|---|---|---|---|---|---|
| 2623 | 김서준 | BRONZE | 2024-09-18 20:22:57 | 4 | 17,088,500.00 |
| 2894 | 한미영 | BRONZE | 2024-05-24 09:44:28 | 1 | 14,204,200.00 |
| 1724 | 김서연 | BRONZE | 2024-08-03 08:13:36 | 3 | 12,494,600.00 |
| 3667 | 안예은 | BRONZE | 2024-12-10 12:12:19 | 2 | 12,260,100.00 |
| 1186 | 박진우 | BRONZE | 2024-10-24 20:05:01 | 3 | 9,588,499.00 |
| 2814 | 김수민 | BRONZE | 2024-08-26 20:19:31 | 2 | 8,290,525.00 |
| 2236 | 문정식 | BRONZE | 2024-11-21 11:34:35 | 4 | 7,629,400.00 |
11. 모든 주문에서 카드로만 결제한 고객을 찾으세요.
카드 외 결제 수단(kakao_pay, naver_pay, bank_transfer 등)을 한 번도 사용하지 않은 고객입니다.
힌트 1: 전칭 한정: "모든 결제가 카드" = "카드가 아닌 결제가 존재하지 않는다".
NOT EXISTS (... payments WHERE method != 'card' ...)를 사용합니다.
주문 이력이 있는 고객만 대상으로 합니다.
정답
SELECT
c.id,
c.name,
c.grade,
COUNT(DISTINCT o.id) AS order_count,
ROUND(SUM(o.total_amount), 0) AS total_spent
FROM customers AS c
INNER JOIN orders AS o ON c.id = o.customer_id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
-- 카드가 아닌 결제가 한 건도 없어야 함
AND NOT EXISTS (
SELECT 1
FROM payments AS p
INNER JOIN orders AS o2 ON p.order_id = o2.id
WHERE o2.customer_id = c.id
AND p.method != 'card'
)
-- 결제 이력이 있어야 함
AND EXISTS (
SELECT 1
FROM payments AS p
INNER JOIN orders AS o3 ON p.order_id = o3.id
WHERE o3.customer_id = c.id
AND p.method = 'card'
)
GROUP BY c.id, c.name, c.grade
ORDER BY total_spent DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| id | name | grade | order_count | total_spent |
|---|---|---|---|---|
| 4213 | 오서윤 | VIP | 1 | 13,895,400.00 |
| 4179 | 이광수 | VIP | 1 | 8,319,100.00 |
| 3138 | 김서준 | GOLD | 4 | 6,843,700.00 |
| 2027 | 이영환 | BRONZE | 2 | 6,285,000.00 |
| 3785 | 홍명자 | GOLD | 4 | 5,791,400.00 |
| 1120 | 서상훈 | BRONZE | 5 | 5,519,100.00 |
| 2119 | 서도윤 | BRONZE | 3 | 5,265,400.00 |
12. "모든 종류의 문의 카테고리"를 경험한 고객을 찾으세요.
complaints 테이블의 모든 category 값에 대해 최소 1건의 문의를 제출한 고객입니다.
힌트 1: 전칭 한정의 이중 부정 패턴:
"모든 카테고리에 문의가 있다" = "문의가 없는 카테고리가 존재하지 않는다".
NOT EXISTS (SELECT category FROM (SELECT DISTINCT category FROM complaints) WHERE NOT EXISTS (... 해당 고객의 해당 카테고리 문의)).
정답
SELECT
c.id,
c.name,
c.grade,
(SELECT COUNT(DISTINCT cp.category) FROM complaints AS cp WHERE cp.customer_id = c.id) AS category_count
FROM customers AS c
WHERE NOT EXISTS (
-- 문의가 없는 카테고리가 하나라도 있으면 제외
SELECT DISTINCT cp_all.category
FROM complaints AS cp_all
WHERE NOT EXISTS (
SELECT 1
FROM complaints AS cp
WHERE cp.customer_id = c.id
AND cp.category = cp_all.category
)
)
ORDER BY c.name;
실행 결과 (총 8행 중 상위 7행)
| id | name | grade | category_count |
|---|---|---|---|
| 258 | 김경희 | GOLD | 7 |
| 97 | 김병철 | VIP | 7 |
| 489 | 박경숙 | VIP | 7 |
| 549 | 이미정 | VIP | 7 |
| 98 | 이영자 | VIP | 7 |
| 744 | 이채원 | VIP | 7 |
| 1388 | 이현지 | VIP | 7 |
13. "가격이 100만원 이상인 상품만 구매한" 고객을 찾으세요.
저가 상품을 한 번도 구매하지 않은 프리미엄 고객입니다.
힌트 1: "100만원 미만 상품 구매가 존재하지 않는다":
NOT EXISTS (... order_items JOIN products WHERE price < 1000000 AND customer_id = c.id).
동시에 주문 이력은 있어야 합니다(EXISTS).
정답
SELECT
c.id,
c.name,
c.grade,
COUNT(DISTINCT o.id) AS order_count,
ROUND(SUM(o.total_amount), 0) AS total_spent,
ROUND(AVG(o.total_amount), 0) AS avg_order_value
FROM customers AS c
INNER JOIN orders AS o ON c.id = o.customer_id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
-- 100만원 미만 상품 구매가 없어야 함
AND NOT EXISTS (
SELECT 1
FROM order_items AS oi
INNER JOIN orders AS o2 ON oi.order_id = o2.id
INNER JOIN products AS p ON oi.product_id = p.id
WHERE o2.customer_id = c.id
AND p.price < 1000000
AND o2.status NOT IN ('cancelled', 'returned', 'return_requested')
)
GROUP BY c.id, c.name, c.grade
ORDER BY total_spent DESC
LIMIT 10;
실행 결과 (2행)
| id | name | grade | order_count | total_spent | avg_order_value |
|---|---|---|---|---|---|
| 4137 | 김재호 | BRONZE | 1 | 4,352,405.00 | 4,352,405.00 |
| 4973 | 류준호 | SILVER | 1 | 1,204,536.00 | 1,204,536.00 |
14. 재구매 상품 쌍을 찾으세요 — 같은 고객이 다른 주문에서 동일 상품을 2번 이상 구매한 경우입니다.
EXISTS로 "동일 고객, 동일 상품, 다른 주문"을 확인합니다.
힌트 1: order_items를 기준으로, EXISTS에서 같은 product_id, 같은 customer_id이지만
다른 order_id인 레코드가 있는지 확인합니다.
고객-상품 조합별로 재구매 횟수를 집계합니다.
정답
SELECT
c.name AS customer_name,
p.name AS product_name,
COUNT(DISTINCT oi.order_id) AS purchase_count,
SUM(oi.quantity) AS total_qty
FROM order_items AS oi
INNER JOIN orders AS o ON oi.order_id = o.id
INNER JOIN customers AS c ON o.customer_id = c.id
INNER JOIN products AS p ON oi.product_id = p.id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
AND EXISTS (
-- 같은 고객이 같은 상품을 다른 주문에서 구매
SELECT 1
FROM order_items AS oi2
INNER JOIN orders AS o2 ON oi2.order_id = o2.id
WHERE o2.customer_id = o.customer_id
AND oi2.product_id = oi.product_id
AND oi2.order_id != oi.order_id
AND o2.status NOT IN ('cancelled', 'returned', 'return_requested')
)
GROUP BY c.id, c.name, p.id, p.name
ORDER BY purchase_count DESC
LIMIT 15;
실행 결과 (총 15행 중 상위 7행)
| customer_name | product_name | purchase_count | total_qty |
|---|---|---|---|
| 이영자 | G.SKILL Trident Z5 DDR5 64GB 6000MHz 화이트 | 37 | 37 |
| 이영자 | 삼성 오디세이 G7 32 화이트 | 25 | 25 |
| 이영자 | 삼성 DDR5 32GB PC5-38400 | 24 | 24 |
| 김병철 | 삼성 DDR4 32GB PC4-25600 | 23 | 30 |
| 김병철 | be quiet! Light Base 900 | 22 | 28 |
| 김병철 | AMD Ryzen 9 9900X | 22 | 22 |
| 김병철 | G.SKILL Trident Z5 DDR5 64GB 6000MHz 화이트 | 21 | 21 |
15. "NOT EXISTS vs LEFT JOIN IS NULL" 안티 조인 패턴을 비교하세요.
2024년에 리뷰를 남기지 않은 상품을 두 가지 방법으로 조회하고, 결과가 동일한지 확인합니다.
힌트 1: 방법 1: NOT EXISTS (SELECT 1 FROM reviews WHERE product_id = p.id AND created_at LIKE '2024%').
방법 2: LEFT JOIN reviews ON ... WHERE r.id IS NULL.
두 쿼리를 EXCEPT로 비교하면 차집합이 비어 있어야 합니다.
정답
-- 방법 1: NOT EXISTS
SELECT p.id, p.name
FROM products AS p
WHERE p.is_active = 1
AND NOT EXISTS (
SELECT 1
FROM reviews AS r
WHERE r.product_id = p.id
AND r.created_at LIKE '2024%'
)
ORDER BY p.id;
-- 방법 2: LEFT JOIN ... IS NULL
SELECT p.id, p.name
FROM products AS p
LEFT JOIN reviews AS r
ON r.product_id = p.id
AND r.created_at LIKE '2024%'
WHERE p.is_active = 1
AND r.id IS NULL
ORDER BY p.id;
-- 동일성 검증: 차집합이 비어야 함
SELECT p.id, p.name
FROM products AS p
WHERE p.is_active = 1
AND NOT EXISTS (
SELECT 1 FROM reviews AS r
WHERE r.product_id = p.id AND r.created_at LIKE '2024%'
)
EXCEPT
SELECT p.id, p.name
FROM products AS p
LEFT JOIN reviews AS r
ON r.product_id = p.id AND r.created_at LIKE '2024%'
WHERE p.is_active = 1
AND r.id IS NULL;