Devin.KR

서브쿼리 응용 - 상관 서브쿼리와 EXISTS

개발자KR 조회 1

이 장에서 배우는 것

앞 장에서는 조인 결과의 행 수를 먼저 세어보고 카티션 곱과 중복을 예측하는 연습을 했다. 이번 장은 같은 온라인 서점 스키마를 그대로 두고, 조인 대신 서브쿼리로 같은 질문에 답하는 법을 다룬다. 단일행·다중행·다중컬럼 서브쿼리를 구분해서 쓰는 법, 바깥 행마다 다시 실행되는 상관 서브쿼리의 동작, 그리고 실무에서 가장 자주 사고로 이어지는 NOT IN과 NULL의 조합을 정리한다.

  • 단일행·다중행·다중컬럼 서브쿼리를 상황에 맞는 연산자로 구분해 쓴다
  • 상관 서브쿼리가 바깥 행마다 다시 실행되는 절차를 그림으로 이해한다
  • NOT IN과 NOT EXISTS가 NULL을 다루는 방식 차이를 설명하고 트랩을 피한다
  • SELECT 절의 스칼라 서브쿼리를 조인으로 바꿔 쓸 수 있다

문제 상황

이 장도 회원·도서·주문·주문상세·리뷰 다섯 개 테이블을 그대로 쓴다. 마케팅팀에서 "별점 3점 미만을 준 적이 한 번도 없는 회원에게 감사 쿠폰을 보내겠다"는 요청이 왔다. 담당자는 다음과 같이 짰다.

SELECT member_name
FROM 회원
WHERE member_id NOT IN (SELECT member_id FROM 리뷰 WHERE rating < 3);

그런데 결과가 0건이었다. 회원 4명 중 낮은 평점을 준 사람이 정말 하나도 없는데도 그렇다. 원인은 리뷰 테이블에 있다. 이 서점은 회원이 탈퇴해도 리뷰는 지우지 않고 회원 참조만 NULL로 바꿔 보관한다. 그렇게 남은 리뷰 한 건이 평점 3점 미만이었고, 그 한 건 때문에 NOT IN의 비교 대상 목록에 NULL이 섞여 들어갔다. 아래는 이 상황과 직접 관련된 두 테이블의 일부다.

회원 4명은 모두 낮은 평점을 준 적이 없다
member_idmember_namegradejoin_date
1김도윤VIP2023-01-15
2이서연GENERAL2023-03-22
3박지호VIP2023-05-10
4최유나GENERAL2024-02-18
탈퇴 회원이 남긴 리뷰 한 건(review_id 9007)이 member_id를 NULL로 갖는다
review_idbook_idmember_idrating
900510544
90061052NULL
9007104NULL1

이 장은 이 사고의 원인을 정확히 설명하는 데서 출발해, 서브쿼리 전반을 다시 정리한다.

단일행·다중행·다중컬럼 서브쿼리

서브쿼리는 반환하는 행과 컬럼의 개수에 따라 쓸 수 있는 연산자가 다르다. 이 구분을 지키지 않으면 실행 시점에 오류가 난다.

단일행 서브쿼리

서브쿼리가 정확히 값 하나를 반환할 때는 =, >, < 같은 단일행 비교 연산자를 쓴다. 평균 가격보다 비싼 도서를 찾는 질문이 전형적인 예다.

SELECT title, price
FROM 도서
WHERE price > (SELECT AVG(price) FROM 도서);

다중행 서브쿼리와 IN·ANY·ALL

서브쿼리가 여러 행을 반환하면 IN, ANY, ALL 중 하나를 골라야 한다. IN은 목록 중 하나와 같은지, ALL은 목록의 모든 값보다 큰지(또는 작은지), ANY는 목록 중 하나보다만 크면 되는지를 따진다. 소설 카테고리 도서 가격과 비교해보면 차이가 분명하다.

-- 소설 카테고리 최고가(15000)보다 비싼 도서만
SELECT title FROM 도서
WHERE price > ALL (SELECT price FROM 도서 WHERE category = '소설');

-- 소설 카테고리 최저가(13500)보다만 비싸도 포함
SELECT title FROM 도서
WHERE price > ANY (SELECT price FROM 도서 WHERE category = '소설');

ALL 버전은 SQL 실전 가이드·데이터 모델링 입문·알고리즘 도감 3권만 남는다. ANY 버전은 겨울 산책(15000원)까지 포함해 4권이 남는다. 겨울 산책은 소설 카테고리의 최저가(13500원)보다는 비싸서 ANY 조건은 만족하지만, 최고가(15000원)와 같아서 ALL 조건은 만족하지 못하기 때문이다. 실무에서는 ALL·ANY보다 의미가 더 뚜렷한 MAX·MIN 단일행 서브쿼리로 바꿔 쓰는 경우가 많다.

다중컬럼 서브쿼리

비교할 컬럼이 두 개 이상이면 컬럼 목록을 괄호로 묶어 한 번에 비교한다. 카테고리별 최고가 도서를 찾을 때 유용하다.

SELECT title, category, price
FROM 도서
WHERE (category, price) IN (
  SELECT category, MAX(price) FROM 도서 GROUP BY category
);

이렇게 쓰지 않으면 카테고리별로 서브쿼리를 따로 돌리거나, 조인 후 GROUP BY로 최고가를 구해 다시 비교하는 번거로운 절차를 거쳐야 한다.

서브쿼리는 반환하는 행·컬럼 개수로 쓸 수 있는 연산자가 갈린다
종류반환 형태사용 가능 연산자예시 상황
단일행 서브쿼리값 1개=, >, <, >=, <=, <>평균 가격보다 비싼 도서
다중행 서브쿼리한 컬럼, 여러 행IN, ANY, ALL평점 4점 이상 리뷰가 달린 도서
다중컬럼 서브쿼리여러 컬럼, 여러 행IN (컬럼 목록 비교)카테고리별 최고가 도서
상관 서브쿼리바깥 행마다 다른 값EXISTS, NOT EXISTS, 비교 연산자리뷰가 없는 주문 항목

상관 서브쿼리는 바깥 행마다 다시 실행된다

지금까지 본 서브쿼리는 바깥 쿼리와 무관하게 한 번만 실행되고 그 결과를 바깥 쿼리가 재사용한다. 상관 서브쿼리(correlated subquery)는 다르다. 서브쿼리 안에서 바깥 테이블의 컬럼을 참조하기 때문에, 바깥 쿼리가 행을 하나씩 넘길 때마다 그 값을 대입해 서브쿼리를 다시 실행한다. "완료된 주문 중에서 아직 리뷰를 남기지 않은 항목"을 찾는 쿼리가 이 방식이다.

SELECT o.order_id, o.member_id, od.book_id
FROM 주문 o
JOIN 주문상세 od ON o.order_id = od.order_id
WHERE o.status = '완료'
  AND NOT EXISTS (
    SELECT 1 FROM 리뷰 r
    WHERE r.member_id = o.member_id AND r.book_id = od.book_id
  );

주문상세 한 행마다 r.member_id = o.member_id AND r.book_id = od.book_id 조건에 그 행의 값이 대입된 채로 리뷰 테이블을 다시 훑는다. 아래 그림은 서로 다른 두 행이 서로 다른 조건으로 서브쿼리를 실행해 다른 결론에 도달하는 과정을 보여준다.

상관 서브쿼리는 바깥 행이 바뀔 때마다 안쪽 조건도 바뀌어 다시 실행된다

바깥 쿼리가 훑는 행이 많을수록 서브쿼리도 그만큼 여러 번 실행된다는 뜻이므로, 상관 서브쿼리를 남발하면 실행 비용이 늘어난다. 다만 이 비용은 인덱스 유무에 따라 크게 달라지므로, 여기서는 "여러 번 실행된다"는 개념만 정확히 잡아두고 넘어간다.

EXISTS와 IN의 차이, 그리고 스칼라 서브쿼리를 조인으로 바꾸기

EXISTS는 서브쿼리가 반환하는 값의 내용이 아니라 "행이 존재하는가"만 확인한다. 그래서 EXISTS (SELECT 1 ...)와 EXISTS (SELECT * ...)는 완전히 같은 결과를 낸다. 반면 IN은 서브쿼리가 반환한 값 목록과 바깥 컬럼을 실제로 비교한다. 이 차이가 NULL을 만나면 문제가 된다.

문제 상황에서 본 쿼리를 다시 보자. NOT IN의 서브쿼리는 평점이 3점 미만인 리뷰의 member_id를 반환하는데, 그 목록에 탈퇴 회원의 NULL이 섞여 있다. SQL에서 NOT IN (list)은 바깥값 <> list[1] AND 바깥값 <> list[2] AND ...와 같은 의미로 풀리는데, 이 중 하나라도 NULL과 비교하면 그 항이 UNKNOWN이 되고, AND로 묶인 전체 조건도 UNKNOWN이 되어 그 행은 결과에서 빠진다. 목록에 NULL이 하나라도 있으면 회원이 누구든 상관없이 전부 이렇게 된다.

NOT EXISTS로 같은 질문을 쓰면 이 문제가 생기지 않는다. 상관 조건 r.member_id = m.member_id는 회원마다 자기 자신의 리뷰만 비교하므로, member_id가 NULL인 리뷰는 애초에 어떤 회원과도 매칭되지 않고 조용히 무시된다.

SELECT m.member_name
FROM 회원 m
WHERE NOT EXISTS (
  SELECT 1 FROM 리뷰 r
  WHERE r.member_id = m.member_id AND r.rating < 3
);
NOT IN은 목록에 NULL이 있으면 전체가 사라지지만 NOT EXISTS는 영향받지 않는다

스칼라 서브쿼리는 SELECT 절에 값 하나를 끼워 넣는 상관 서브쿼리다. 예를 들어 회원별 최근 완료 주문일을 붙이는 쿼리를 상관 스칼라 서브쿼리로 쓸 수도 있고, 조인과 GROUP BY로 바꿔 쓸 수도 있다. 두 방식은 결과가 같더라도 읽는 방식이 다르다. 스칼라 서브쿼리는 "회원마다 이 값을 하나씩 구해 붙인다"는 절차적인 느낌이 강하고, 조인은 "두 집합을 연결한 뒤 묶어서 집계한다"는 집합적인 느낌이 강하다. 회원 수가 많고 서브쿼리 안의 조건이 인덱스를 잘 타지 못하는 상황이라면 조인으로 바꿔 쓰는 편이 읽기에도, 실행 계획을 예측하기에도 유리하다.

완성 코드

-- 스키마: 회원, 도서, 주문, 주문상세, 리뷰
CREATE TABLE 회원 (
  member_id INT PRIMARY KEY,
  member_name VARCHAR(20) NOT NULL,
  grade VARCHAR(10) NOT NULL,
  join_date DATE NOT NULL
);

CREATE TABLE 도서 (
  book_id INT PRIMARY KEY,
  title VARCHAR(50) NOT NULL,
  category VARCHAR(10) NOT NULL,
  price INT NOT NULL,
  publisher VARCHAR(20) NOT NULL
);

CREATE TABLE 주문 (
  order_id INT PRIMARY KEY,
  member_id INT NOT NULL,
  order_date DATE NOT NULL,
  status VARCHAR(10) NOT NULL,
  FOREIGN KEY (member_id) REFERENCES 회원(member_id)
);

CREATE TABLE 주문상세 (
  order_id INT NOT NULL,
  book_id INT NOT NULL,
  quantity INT NOT NULL,
  unit_price INT NOT NULL,
  PRIMARY KEY (order_id, book_id),
  FOREIGN KEY (order_id) REFERENCES 주문(order_id),
  FOREIGN KEY (book_id) REFERENCES 도서(book_id)
);

-- 탈퇴 회원의 리뷰는 지우지 않고 member_id만 NULL로 남긴다
CREATE TABLE 리뷰 (
  review_id INT PRIMARY KEY,
  book_id INT NOT NULL,
  member_id INT NULL,
  rating INT NULL,
  review_date DATE NOT NULL,
  FOREIGN KEY (book_id) REFERENCES 도서(book_id),
  FOREIGN KEY (member_id) REFERENCES 회원(member_id)
);

INSERT INTO 회원 (member_id, member_name, grade, join_date) VALUES
(1, '김도윤', 'VIP', '2023-01-15'),
(2, '이서연', 'GENERAL', '2023-03-22'),
(3, '박지호', 'VIP', '2023-05-10'),
(4, '최유나', 'GENERAL', '2024-02-18');

INSERT INTO 도서 (book_id, title, category, price, publisher) VALUES
(101, 'SQL 실전 가이드', 'IT', 28000, '한빛출판'),
(102, '데이터 모델링 입문', 'IT', 25000, '인사이트'),
(103, '겨울 산책', '소설', 15000, '문학동네'),
(104, '알고리즘 도감', 'IT', 22000, '영진닷컴'),
(105, '별을 헤는 밤', '소설', 13500, '창비');

INSERT INTO 주문 (order_id, member_id, order_date, status) VALUES
(5001, 1, '2024-06-01', '완료'),
(5002, 2, '2024-06-03', '완료'),
(5003, 1, '2024-06-10', '완료'),
(5004, 3, '2024-06-12', '취소'),
(5005, 4, '2024-06-15', '완료'),
(5006, 3, '2024-06-18', '완료');

INSERT INTO 주문상세 (order_id, book_id, quantity, unit_price) VALUES
(5001, 101, 1, 28000),
(5001, 103, 2, 15000),
(5002, 102, 1, 25000),
(5003, 104, 1, 22000),
(5004, 101, 1, 28000),
(5005, 105, 3, 13500),
(5006, 103, 1, 15000);

INSERT INTO 리뷰 (review_id, book_id, member_id, rating, review_date) VALUES
(9001, 101, 1, 5, '2024-06-05'),
(9002, 101, 2, 4, '2024-06-08'),
(9003, 103, 1, 3, '2024-06-06'),
(9004, 102, 2, 5, '2024-06-09'),
(9005, 105, 4, 4, '2024-06-20'),
(9006, 105, 2, NULL, '2024-06-22'),
(9007, 104, NULL, 1, '2024-06-25');

-- 1. 단일행 서브쿼리: 평균가보다 비싼 도서
SELECT title, price
FROM 도서
WHERE price > (SELECT AVG(price) FROM 도서)
ORDER BY price DESC;

-- 2. 다중행 서브쿼리: 평점 4점 이상 리뷰가 달린 도서
SELECT title
FROM 도서
WHERE book_id IN (SELECT book_id FROM 리뷰 WHERE rating >= 4)
ORDER BY book_id;

-- 3. 다중컬럼 서브쿼리: 카테고리별 최고가 도서
SELECT title, category, price
FROM 도서
WHERE (category, price) IN (
  SELECT category, MAX(price) FROM 도서 GROUP BY category
)
ORDER BY category;

-- 4. NOT IN 트랩: 탈퇴 회원 리뷰의 NULL 때문에 0건이 나온다
SELECT member_name
FROM 회원
WHERE member_id NOT IN (SELECT member_id FROM 리뷰 WHERE rating < 3);

-- 5. NOT EXISTS로 고친 버전
SELECT m.member_name
FROM 회원 m
WHERE NOT EXISTS (
  SELECT 1 FROM 리뷰 r
  WHERE r.member_id = m.member_id AND r.rating < 3
)
ORDER BY m.member_id;

-- 6. 상관 서브쿼리 EXISTS: 완료 주문 중 리뷰를 남기지 않은 항목
SELECT o.order_id, o.member_id, od.book_id
FROM 주문 o
JOIN 주문상세 od ON o.order_id = od.order_id
WHERE o.status = '완료'
  AND NOT EXISTS (
    SELECT 1 FROM 리뷰 r
    WHERE r.member_id = o.member_id AND r.book_id = od.book_id
  )
ORDER BY o.order_id;

-- 7. 스칼라 서브쿼리: 회원별 최근 완료 주문일
SELECT m.member_name,
       (SELECT MAX(o.order_date)
          FROM 주문 o
         WHERE o.member_id = m.member_id AND o.status = '완료') AS last_order_date
FROM 회원 m
ORDER BY m.member_id;

-- 8. 7번을 조인으로 전환
SELECT m.member_name, MAX(o.order_date) AS last_order_date
FROM 회원 m
JOIN 주문 o ON o.member_id = m.member_id AND o.status = '완료'
GROUP BY m.member_id, m.member_name
ORDER BY m.member_id;

줄별 해설

  • 리뷰 테이블은 member_id와 rating을 모두 NULL 허용으로 정의했다. 탈퇴 회원의 리뷰(member_id NULL)와 별점 없이 댓글만 남긴 리뷰(rating NULL)를 실제 데이터로 재현하기 위해서다.
  • 1번 쿼리는 평균 가격이라는 값 하나와 비교하므로 단일행 연산자 >를 쓴다.
  • 2번 쿼리는 조건을 만족하는 book_id가 여러 개일 수 있으므로 IN을 쓴다.
  • 3번 쿼리는 (카테고리, 최고가) 쌍을 한 번에 비교하는 다중컬럼 서브쿼리다.
  • 4번 쿼리는 문제 상황에서 본 그대로다. 서브쿼리가 반환하는 목록에 NULL이 하나만 섞여도 NOT IN 전체가 무의미해진다.
  • 5번 쿼리는 상관 조건 r.member_id = m.member_id로 회원마다 자기 리뷰만 확인하므로 NULL 리뷰의 영향을 받지 않는다.
  • 6번 쿼리는 두 개의 상관 조건(r.member_id = o.member_id, r.book_id = od.book_id)을 모두 걸어야 "그 회원이 그 책에 남긴 리뷰"를 정확히 찾는다.
  • 7번과 8번은 같은 결과를 내는 두 가지 방법이다. 7번은 SELECT 절 안에서 회원마다 서브쿼리를 다시 실행하고, 8번은 먼저 조인해 집합을 만든 뒤 한 번에 GROUP BY로 묶는다. 완료 주문이 하나도 없는 회원이 있다면 8번은 INNER JOIN이라 그 회원이 통째로 빠지므로, 그런 경우까지 다루려면 LEFT JOIN으로 바꿔야 한다.

실행 결과

-- 1번 결과
title                | price
----------------------+-------
SQL 실전 가이드      | 28000
데이터 모델링 입문   | 25000
알고리즘 도감        | 22000
(3 rows)

-- 2번 결과
title
---------------------
SQL 실전 가이드
데이터 모델링 입문
별을 헤는 밤
(3 rows)

-- 3번 결과
title            | category | price
------------------+----------+-------
SQL 실전 가이드  | IT       | 28000
겨울 산책        | 소설     | 15000
(2 rows)

-- 4번 결과
member_name
------------
(0 rows)

-- 5번 결과
member_name
------------
김도윤
이서연
박지호
최유나
(4 rows)

-- 6번 결과
order_id | member_id | book_id
---------+-----------+--------
5003     | 1         | 104
5006     | 3         | 103
(2 rows)

-- 7번, 8번 결과(동일)
member_name | last_order_date
-------------+------------------
김도윤      | 2024-06-10
이서연      | 2024-06-03
박지호      | 2024-06-18
최유나      | 2024-06-15
(4 rows)

실무에서 자주 틀리는 것

NOT IN에 NULL이 섞여 결과가 통째로 사라진다

-- 틀린 코드
SELECT member_name FROM 회원
WHERE member_id NOT IN (SELECT member_id FROM 리뷰 WHERE rating < 3);
-- 고친 코드
SELECT m.member_name FROM 회원 m
WHERE NOT EXISTS (
  SELECT 1 FROM 리뷰 r
  WHERE r.member_id = m.member_id AND r.rating < 3
);

서브쿼리가 선택하는 컬럼이 NULL을 가질 수 있다면 NOT IN은 위험하다. NOT EXISTS로 바꾸거나, 정말 NOT IN을 써야 한다면 서브쿼리에 AND member_id IS NOT NULL을 추가해 NULL을 미리 제거해야 한다.

상관 조건을 빠뜨려 모든 행이 같은 결과를 받는다

-- 틀린 코드: 바깥 테이블과 연결하는 조건이 없다
SELECT o.order_id, od.book_id
FROM 주문 o
JOIN 주문상세 od ON o.order_id = od.order_id
WHERE o.status = '완료'
  AND NOT EXISTS (SELECT 1 FROM 리뷰 r WHERE r.rating < 3);
-- 고친 코드: 회원과 도서 둘 다 바깥 행과 연결한다
SELECT o.order_id, od.book_id
FROM 주문 o
JOIN 주문상세 od ON o.order_id = od.order_id
WHERE o.status = '완료'
  AND NOT EXISTS (
    SELECT 1 FROM 리뷰 r
    WHERE r.member_id = o.member_id AND r.book_id = od.book_id
  );

틀린 코드의 서브쿼리는 바깥 행과 전혀 무관하다. "평점 3점 미만 리뷰가 하나라도 존재하는가"라는 한 가지 질문에 대한 답(존재함)이 모든 바깥 행에 똑같이 적용되어, 결국 모든 행에서 NOT EXISTS가 거짓이 되고 결과가 0건이 된다. 겉보기에는 상관 서브쿼리처럼 보이지만 실제로는 상수 서브쿼리다.

다중행 서브쿼리에 단일행 연산자를 쓴다

-- 틀린 코드: 서브쿼리가 여러 행을 반환할 수 있다
SELECT title FROM 도서
WHERE book_id = (SELECT book_id FROM 리뷰 WHERE rating >= 4);
-- 고친 코드
SELECT title FROM 도서
WHERE book_id IN (SELECT book_id FROM 리뷰 WHERE rating >= 4);

이 예제 데이터에서는 평점 4점 이상 리뷰가 book_id 101, 102, 105에 걸쳐 있으므로 서브쿼리가 여러 행을 반환한다. =는 값이 정확히 하나일 때만 쓸 수 있으므로 실행 시점에 오류가 난다.

스칼라 서브쿼리가 두 행 이상을 반환할 위험을 방치한다

-- 틀린 코드: 회원 한 명이 완료 주문을 두 건 이상 가질 수 있다
SELECT m.member_name,
       (SELECT o.order_date FROM 주문 o
         WHERE o.member_id = m.member_id AND o.status = '완료') AS 주문일
FROM 회원 m;
-- 고친 코드: 집계 함수로 값을 하나로 좁힌다
SELECT m.member_name,
       (SELECT MAX(o.order_date) FROM 주문 o
         WHERE o.member_id = m.member_id AND o.status = '완료') AS 최근주문일
FROM 회원 m;

회원 1(김도윤)은 완료 주문이 두 건(5001, 5003)이라 틀린 코드는 실행 시점에 오류가 난다. SELECT 절의 스칼라 서브쿼리는 항상 값이 하나뿐이라는 보장이 있을 때만 써야 하며, 그렇지 않다면 집계 함수로 감싸거나 8번 쿼리처럼 조인으로 바꿔야 한다.

한눈에 보기

서브쿼리를 쓸 때 확인해야 할 네 가지 상황
상황핵심 규칙권장 처리
제외 목록에 NULL 가능성이 있음NOT IN은 목록에 NULL이 하나라도 있으면 전체가 사라진다NOT EXISTS를 쓰거나 IS NOT NULL로 NULL을 미리 제거
서브쿼리가 여러 행을 반환단일행 연산자(=, >)에 다중행 결과를 넣으면 오류IN, ANY, ALL로 교체
상관 서브쿼리에 상관 조건 누락바깥 행과 무관하게 항상 같은 값을 반환해 모든 행이 같은 결과를 얻는다WHERE 절에 바깥 테이블 컬럼을 반드시 연결
SELECT 절 스칼라 서브쿼리가 2행 이상 반환할 위험실행 시점 오류로 이어진다집계 함수로 감싸거나 조인으로 전환
MySQL 8과 Oracle의 실제 차이는 생각보다 적다
항목MySQL 8Oracle비고
FROM 없는 SELECTSELECT 1; 그대로 가능SELECT 1 FROM DUAL; 필요스칼라 값 하나만 뽑을 때 차이가 난다
상관 서브쿼리로 같은 테이블 UPDATE갱신 대상 테이블을 서브쿼리에서 직접 참조 불가, 파생 테이블로 감싸야 함상관 서브쿼리로 직접 갱신 가능UPDATE·DELETE 구문에서 자주 걸린다
다중컬럼 IN 서브쿼리지원지원문법이 동일하다
NOT IN과 NULL의 관계표준과 동일하게 UNKNOWN 처리표준과 동일하게 UNKNOWN 처리벤더 차이가 아니라 표준 SQL 규칙이다

연습 문제

  1. 총 주문 수량(quantity 합)이 2권 이상인 도서 제목을, 주문상세를 book_id로 묶어 상관 서브쿼리 EXISTS로 구하는 쿼리를 작성하라.
  2. 문제 상황의 NOT IN 쿼리를 고치되, NOT EXISTS 대신 서브쿼리에 IS NOT NULL 조건을 추가하는 방식으로 다시 작성하고 결과가 같은지 확인하라.
  3. 도서별 평균 평점을 SELECT 절 스칼라 서브쿼리로 구하는 쿼리를 작성하고, 같은 결과를 내는 조인 버전으로 바꿔 써라.
  4. 다중컬럼 서브쿼리를 이용해 카테고리별 최저가 도서 제목과 가격을 구하는 쿼리를 작성하라.

정답과 해설

  1. SELECT title FROM 도서 d
    WHERE EXISTS (
      SELECT 1 FROM 주문상세 od
      WHERE od.book_id = d.book_id
      GROUP BY od.book_id
      HAVING SUM(od.quantity) >= 2
    )
    ORDER BY d.book_id;
    

    book_id별 수량 합은 101이 2(주문 5001과 5004), 103이 3(주문 5001과 5006), 105가 3(주문 5005)이다. 결과는 SQL 실전 가이드, 겨울 산책, 별을 헤는 밤 3권이다.

  2. SELECT member_name FROM 회원
    WHERE member_id NOT IN (
      SELECT member_id FROM 리뷰
      WHERE rating < 3 AND member_id IS NOT NULL
    )
    ORDER BY member_id;
    

    NULL을 미리 걸러내면 서브쿼리 목록에 NULL이 들어가지 않으므로 NOT IN이 정상 동작한다. 5번 쿼리(NOT EXISTS)와 똑같이 회원 4명이 모두 반환된다.

  3. -- 스칼라 서브쿼리 버전
    SELECT b.title,
           (SELECT AVG(r.rating) FROM 리뷰 r WHERE r.book_id = b.book_id) AS 평균평점
    FROM 도서 b
    ORDER BY b.book_id;
    
    -- 조인 버전
    SELECT b.title, AVG(r.rating) AS 평균평점
    FROM 도서 b
    LEFT JOIN 리뷰 r ON r.book_id = b.book_id
    GROUP BY b.book_id, b.title
    ORDER BY b.book_id;
    

    AVG는 NULL을 계산에서 자동으로 제외하므로 rating이 NULL인 review_id 9006은 105번 도서의 평균에서 빠진다. 도서별 평균은 101이 4.5, 102가 5, 103이 3, 104가 1, 105가 4다. 모든 도서가 리뷰를 하나 이상 가지므로 이 예제에서는 INNER JOIN과 LEFT JOIN의 결과가 같지만, 리뷰가 없는 도서까지 0건으로 보여주려면 LEFT JOIN이 필요하다.

  4. SELECT title, category, price
    FROM 도서
    WHERE (category, price) IN (
      SELECT category, MIN(price) FROM 도서 GROUP BY category
    )
    ORDER BY category;
    

    IT 카테고리 최저가는 알고리즘 도감(22000원), 소설 카테고리 최저가는 별을 헤는 밤(13500원)이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.