종합 연습 - 자체 모의 문제 30선과 오답 해설
이 장에서 배우는 것
이 책의 마지막 장은 새 개념을 배우는 자리가 아니라 지금까지 쌓은 지식을 실전처럼 점검하는 자리다. 모델링 판단부터 윈도 함수까지 다룬 내용을 모두 아우르는 자체 모의고사 30문항을 온라인 서점 스키마 하나로 새로 만들고, 정답만이 아니라 틀리기 쉬운 이유까지 정리한다. 채점은 감으로 하지 않고 SQL로 직접 검증하는 방법도 함께 익힌다.
- 모델링 8문항, SQL 기본 12문항, SQL 활용 10문항으로 구성된 모의고사를 스스로 풀어 본다
- 정답과 함께 "왜 그 오답을 고르기 쉬운가"를 설명할 수 있는지 확인한다
- NOT IN의 NULL 함정, 조인 팬아웃(Fan-out), ROLLUP 소계 처리처럼 반복 출제되는 함정을 유형별로 묶는다
- 모의고사 채점을 SELECT 문으로 직접 검증하는 습관을 들인다
문제 상황
서점 시스템 운영팀 네 명이 점심시간마다 모여 SQLD 대비 스터디를 한다. 지난주에는 시중 문제집을 30문항씩 풀고 채점표로 정답만 맞춰 봤는데, 정답률은 높았지만 "이 문항은 왜 이 보기가 답인가"를 서로 설명하지 못하는 경우가 많았다. 특히 NOT IN 서브쿼리에 NULL이 섞이는 경우, 두 테이블을 동시에 조인해서 집계할 때 행이 부풀어 오르는 경우, ROLLUP 결과에서 소계 행을 상세 데이터로 착각하는 경우가 반복해서 틀렸다. 이번 주 스터디는 방식을 바꿔서, 시중 문제를 그대로 가져오는 대신 온라인 서점 스키마로 문제를 직접 만들고 정답을 SQL 검증 스크립트로 확인하기로 했다. 이 장은 그 결과물이다.
문제 유형과 자주 나오는 함정
서른 문항을 다시 훑어보면 함정은 몇 가지 패턴으로 좁혀진다. 모델링 문항은 "식별자를 어떻게 잡을지"와 "이력을 남길지"를 착각하는 경우가 많고, SQL 기본 문항은 NULL 비교와 집계 순서(WHERE와 HAVING) 착각이 대부분이다. SQL 활용 문항은 상관 서브쿼리, 윈도 함수 프레임, ROLLUP·GROUPING SETS 결과 해석에서 갈린다. 아래 표로 유형별 대비 요령을 정리한다.
| 유형 | 자주 나오는 함정 | 대비 요령 | 관련 주제 |
|---|---|---|---|
| 모델링 | 식별자 설계와 이력 관리를 혼동 | 업무 규칙을 먼저 문장으로 적고 키를 정한다 | 식별·비식별 관계 |
| SQL 기본 | NULL 비교, WHERE·HAVING 순서 착각 | 실행 순서(FROM→WHERE→GROUP BY→HAVING)를 손으로 적어 본다 | NULL과 집계 함정 |
| SQL 활용 | 상관 서브쿼리 누락, 프레임 오해 | 서브쿼리가 바깥 행마다 다시 도는지 직접 확인한다 | 서브쿼리·윈도 함수 |
| DML·TCL | 세션 간 커밋 시점 착각 | 세션을 두 개 열어 커밋 전후를 눈으로 비교한다 | 트랜잭션 제어 |
| DDL·제약 | CASCADE를 편의로 남발 | 삭제 정책은 업무 요구사항부터 확인한다 | 무결성 제약 |
이 장의 검증 스크립트에 쓰이는 문법은 MySQL 8과 오라클에서 거의 같지만, 세부 문법은 아래처럼 차이가 있다.
| 항목 | MySQL 8 | 오라클 | 비고 |
|---|---|---|---|
| ROLLUP 문법 | GROUP BY ROLLUP(a,b) | GROUP BY ROLLUP(a,b) | 둘 다 표준 문법 지원, 결과 동일 |
| 소계 행 판별 | GROUPING(칼럼) | GROUPING(칼럼) | 둘 다 동일 함수 제공 |
| 상위 N행 | LIMIT n | FETCH FIRST n ROWS ONLY | 오라클도 12c부터 FETCH FIRST 지원 |
| 자동 채번 | AUTO_INCREMENT | SEQUENCE + TRIGGER 또는 IDENTITY | 이 장 예제는 값 직접 지정으로 우회 |
모의고사 30선
모델링 8문항, SQL 기본 12문항, SQL 활용 10문항이다. 문제 원문은 시중 문제집을 참고하지 않고 온라인 서점 도메인으로 새로 썼다.
모델링 8문항
| 번호 | 문제 요지 | 정답 | 틀리기 쉬운 이유 |
|---|---|---|---|
| M1 | 주문상세 기본키 설계 | (주문번호, 도서번호) 복합키 | 무조건 별도 대리키를 만들어야 한다고 착각 |
| M2 | 회원 등급 변경 이력 반영 여부 | 등급 이력 테이블 분리 | 칼럼 하나로 충분하다고 보고 과거 등급을 버림 |
| M3 | 도서 카테고리를 문자열로 둘지 여부 | 카테고리 테이블 분리 | 목록이 짧다고 트레이드오프 없이 반정규화 |
| M4 | 주문한 회원만 리뷰 작성 가능 규칙 반영 | 주문상세를 참조하도록 식별자 재설계 | FK만 걸고 규칙 검증을 애플리케이션에만 맡김 |
| M5 | 같은 도서 재주문 허용 여부 | 회원+도서를 유니크로 두지 않음 | 회원과 도서 조합을 유니크로 착각해 재주문을 막음 |
| M6 | 주문 총액 칼럼 반정규화 선행 조건 | 상세 변경 시 총액 갱신 로직 필요 | 조회 성능만 좋아진다고 보고 정합성 유지 방안을 빠뜨림 |
| M7 | 회원 탈퇴 시 주문 이력 처리 | 탈퇴 플래그로 논리 삭제 | ON DELETE CASCADE로 주문까지 함께 삭제 |
| M8 | 도서 저자가 여러 명인 경우 | 도서저자 연결 테이블 추가 | 저자 이름을 한 칼럼에 콤마로 이어붙임 |
SQL 기본 12문항
| 번호 | 문제 요지 | 정답 | 틀리기 쉬운 이유 |
|---|---|---|---|
| B1 | 평점이 없는 리뷰 걸러내기 | rating IS NOT NULL | rating <> NULL 로 비교해 항상 결과 없음 |
| B2 | 한 번도 주문 안 된 도서 찾기 | NOT EXISTS 사용 | NOT IN 서브쿼리에 NULL이 섞이면 결과가 통째로 사라짐 |
| B3 | INNER JOIN과 LEFT JOIN 행 수 비교 | 상세 없는 주문 1건만큼 LEFT JOIN이 더 많음 | 주문 건수와 조인 결과 행 수를 같다고 착각 |
| B4 | 집계 칼럼과 비집계 칼럼 동시 조회 | GROUP BY에 비집계 칼럼 포함 | 설정에 따라 통과되는 걸 표준 정답으로 착각 |
| B5 | 카테고리 평균가 조건 필터링 | HAVING 절 사용 | WHERE 절에 집계 조건을 넣어 오류 유발 |
| B6 | 이메일에서 도메인만 추출 | 마지막 '@' 위치 기준 문자열 함수 | 도메인에 점이 여러 개면 위치 계산을 틀림 |
| B7 | 가입 30일 이내 회원 조회 | 날짜 함수로 일수 차이 계산 | 문자열 그대로 비교해 형 변환 오류 위험 |
| B8 | UNION과 UNION ALL 결과 행 수 | 중복 존재 여부에 따라 달라짐 | 두 집합에 중복이 없다고 가정하고 같다고 답함 |
| B9 | 동명 칼럼이 있는 조인 결과 조회 | 테이블 별칭으로 명시 | 이름이 같아도 자동으로 구분된다고 착각 |
| B10 | 커밋 전 다른 세션의 조회 결과 | 기본 격리수준에서는 보이지 않음 | 같은 세션 기준으로 착각 |
| B11 | 가격 0 이상만 허용하는 제약 | CHECK(price >= 0) | NOT NULL만 걸고 값 범위 제약을 생략 |
| B12 | 회원 삭제 시 주문 처리 옵션 | RESTRICT로 삭제 차단 | 편의상 CASCADE를 무조건 선택 |
SQL 활용 10문항
| 번호 | 문제 요지 | 정답 | 틀리기 쉬운 이유 |
|---|---|---|---|
| A1 | 카테고리 평균가보다 비싼 도서 | 상관 서브쿼리 사용 | 서브쿼리가 한 번만 계산된다고 착각(상관관계 누락) |
| A2 | 서브쿼리 결과에 NULL이 있을 때 IN과 EXISTS 비교 | EXISTS가 안전 | 두 방식이 항상 같은 결과를 낸다고 착각 |
| A3 | 카테고리별 매출 순위 매기기 | RANK는 동점이면 다음 등수를 건너뜀 | RANK와 DENSE_RANK가 같은 결과라고 착각 |
| A4 | 주문일 순 누적 매출 계산 | 기본 프레임은 첫 행부터 현재 행까지 | 매 행마다 전체 합계가 나온다고 착각 |
| A5 | 최근 3건 이동 평균 계산 | ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | ROWS와 RANGE 차이를 몰라 동률일 때 결과가 달라짐 |
| A6 | ROLLUP 소계 행 구분 | GROUPING(칼럼)=1로 식별 | 소계 행의 NULL을 실제 데이터의 NULL과 혼동 |
| A7 | 카테고리별·등급별 합계만 따로 필요 | GROUPING SETS((카테고리),(등급)) | CUBE를 써서 불필요한 조합까지 모두 생성 |
| A8 | 리뷰 답글 계층 구조 조회 | 재귀 CTE로 앵커와 재귀 부분 구분 | 재귀 종료 조건을 빠뜨려 무한 루프 위험 |
| A9 | 회원별 최근 주문 1건만 뽑기 | ROW_NUMBER로 PARTITION BY 회원 | GROUP BY와 MAX(날짜)만 써서 다른 칼럼이 어긋남 |
| A10 | 리뷰도 쓰고 주문도 취소한 회원 찾기 | INTERSECT 또는 이중 EXISTS | OR로 묶어 교집합이 아닌 합집합을 구함 |
완성 코드
아래는 온라인 서점 스키마와 표본 데이터, 그리고 B2·A2·A3·A6 문항을 실제로 검증하는 스크립트다. 파일 하나(mock_review.sql)로 그대로 실행할 수 있다.
-- 온라인 서점 스키마 (모의고사 검증용)
CREATE TABLE member (
member_id INT PRIMARY KEY,
name VARCHAR(30) NOT NULL,
grade VARCHAR(10) NOT NULL,
join_date DATE NOT NULL
);
CREATE TABLE book (
book_id INT PRIMARY KEY,
title VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
price INT NOT NULL CHECK (price >= 0),
stock_qty INT NOT NULL CHECK (stock_qty >= 0)
);
CREATE TABLE orders (
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(member_id)
);
CREATE TABLE order_detail (
order_id INT NOT NULL,
book_id INT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price INT NOT NULL,
PRIMARY KEY (order_id, book_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id),
FOREIGN KEY (book_id) REFERENCES book(book_id)
);
CREATE TABLE review (
review_id INT PRIMARY KEY,
member_id INT NOT NULL,
book_id INT NOT NULL,
rating INT,
review_date DATE NOT NULL
);
-- 표본 데이터
INSERT INTO member VALUES
(1,'김도윤','VIP','2023-01-10'),
(2,'이서연','일반','2023-03-22'),
(3,'박준호','일반','2023-05-02'),
(4,'최지우','VIP','2024-01-15'),
(5,'정하늘','일반','2024-02-20');
INSERT INTO book VALUES
(101,'SQL 실전 가이드','IT전문서',28000,12),
(102,'여름의 문','소설',15000,3),
(103,'파이썬 데이터분석','IT전문서',32000,7),
(104,'느긋한 하루','에세이',14000,20),
(105,'안개마을','소설',16000,2),
(106,'자바 기초','IT전문서',26000,0),
(107,'새 도서 입고 예정','에세이',20000,15);
INSERT INTO orders VALUES
(1001,1,'2024-03-02','결제완료'),
(1002,2,'2024-03-05','결제완료'),
(1003,1,'2024-03-10','결제완료'),
(1004,3,'2024-03-12','취소'),
(1005,4,'2024-03-15','결제완료'),
(1006,2,'2024-03-20','결제완료'),
(1007,5,'2024-03-25','결제완료'),
(1008,3,'2024-03-27','결제완료');
INSERT INTO order_detail VALUES
(1001,101,1,28000),
(1001,103,1,32000),
(1002,102,2,15000),
(1003,104,3,14000),
(1004,105,1,16000),
(1005,101,1,28000),
(1005,106,1,26000),
(1006,103,1,32000),
(1006,105,1,16000),
(1007,101,2,28000);
INSERT INTO review VALUES
(9001,1,101,5,'2024-03-04'),
(9002,2,102,4,'2024-03-08'),
(9003,1,103,5,'2024-03-06'),
(9004,4,101,3,'2024-03-18'),
(9005,2,105,4,'2024-03-22');
-- [모의 SQL기본-02] 한 번도 주문되지 않은 도서를 NOT EXISTS로 조회한다
SELECT b.book_id, b.title
FROM book b
WHERE NOT EXISTS (
SELECT 1 FROM order_detail od WHERE od.book_id = b.book_id
);
-- 답안 자동 검증 예시: 정답이 도서 107 한 건인지 확인한다
SELECT CASE WHEN COUNT(*) = 1 THEN '검증통과' ELSE '검증실패' END AS 판정
FROM book b
WHERE NOT EXISTS (SELECT 1 FROM order_detail od WHERE od.book_id = b.book_id)
AND b.book_id = 107;
-- [모의 SQL활용-02] 재고 5권 미만 도서를 결제완료 주문으로 산 회원을 EXISTS로 조회한다
SELECT m.member_id, m.name
FROM member m
WHERE EXISTS (
SELECT 1
FROM orders o
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
WHERE o.member_id = m.member_id
AND o.status = '결제완료'
AND b.stock_qty < 5
)
ORDER BY m.member_id;
-- [모의 SQL활용-03] 카테고리별 매출 합계와 순위를 RANK로 구한다
SELECT b.category,
SUM(od.quantity * od.unit_price) AS total_sales,
RANK() OVER (ORDER BY SUM(od.quantity * od.unit_price) DESC) AS sales_rank
FROM orders o
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
WHERE o.status = '결제완료'
GROUP BY b.category
ORDER BY sales_rank;
-- [모의 SQL활용-06] 카테고리·등급별 매출 소계와 총계를 ROLLUP으로 구한다
SELECT b.category, m.grade,
SUM(od.quantity * od.unit_price) AS total_sales,
GROUPING(b.category) AS is_cat_total,
GROUPING(m.grade) AS is_grade_total
FROM orders o
JOIN member m ON m.member_id = o.member_id
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
WHERE o.status = '결제완료'
GROUP BY ROLLUP(b.category, m.grade)
ORDER BY GROUPING(b.category), b.category, GROUPING(m.grade), m.grade;
줄별 해설
- CREATE TABLE 다섯 개는 회원·도서·주문·주문상세·리뷰 순서로 만들며, order_detail은 (order_id, book_id) 복합키를 기본키로 잡아 M1 문항의 정답과 스키마를 일치시켰다.
- book에 107번 도서를 넣고 어떤 order_detail 행에도 등장시키지 않아, B2 문항의 "한 번도 주문 안 된 도서"를 재현했다.
- orders에 1008번 주문을 만들되 order_detail 행을 하나도 연결하지 않았다. 이 주문이 있어야 LEFT JOIN 결과에 NULL이 섞이는 상황(NOT IN 함정)을 실제로 보여줄 수 있다.
- [모의 SQL기본-02] 쿼리는 NOT EXISTS로 도서마다 order_detail 존재 여부를 다시 확인하므로, 1008번 주문이 있어도 결과가 흔들리지 않는다.
- 검증 SELECT는 실제 채점 방식을 보여준다. 정답 개수와 조건을 CASE로 비교해 사람이 눈으로 확인하지 않아도 되게 만든다.
- [모의 SQL활용-02] 쿼리는 EXISTS 안에서 o.member_id = m.member_id로 바깥 행을 다시 참조하는 상관 서브쿼리다. 이 참조를 빼면 항상 같은 결과만 나와 A1·A2 문항이 지적하는 함정에 그대로 걸린다.
- [모의 SQL활용-03] 쿼리는 RANK를 매출 합계 내림차순으로 매긴다. GROUP BY로 먼저 카테고리별 합계를 만든 뒤 그 결과에 윈도 함수를 적용하는 순서를 눈으로 확인할 수 있다.
- [모의 SQL활용-06] 쿼리는 GROUP BY ROLLUP(b.category, m.grade)로 상세행, 카테고리 소계, 전체 총계를 한 번에 만든다. GROUPING 함수로 소계·총계 행을 구분해 A6 문항의 정답 근거를 그대로 보여준다.
실행 결과
$ mysql bookstore < mock_review.sql
+---------+--------------------------+
| book_id | title |
+---------+--------------------------+
| 107 | 새 도서 입고 예정 |
+---------+--------------------------+
1 row in set
+--------------+
| 판정 |
+--------------+
| 검증통과 |
+--------------+
1 row in set
+-----------+--------+
| member_id | name |
+-----------+--------+
| 2 | 이서연 |
| 4 | 최지우 |
+-----------+--------+
2 rows in set
+--------------+-------------+------------+
| category | total_sales | sales_rank |
+--------------+-------------+------------+
| IT전문서 | 202000 | 1 |
| 소설 | 46000 | 2 |
| 에세이 | 42000 | 3 |
+--------------+-------------+------------+
3 rows in set
+--------------+--------+-------------+--------------+----------------+
| category | grade | total_sales | is_cat_total | is_grade_total |
+--------------+--------+-------------+--------------+----------------+
| IT전문서 | VIP | 114000 | 0 | 0 |
| IT전문서 | 일반 | 88000 | 0 | 0 |
| IT전문서 | NULL | 202000 | 0 | 1 |
| 소설 | 일반 | 46000 | 0 | 0 |
| 소설 | NULL | 46000 | 0 | 1 |
| 에세이 | VIP | 42000 | 0 | 0 |
| 에세이 | NULL | 42000 | 0 | 1 |
| NULL | NULL | 290000 | 1 | 1 |
+--------------+--------+-------------+--------------+----------------+
8 rows in set
실무에서 자주 틀리는 것
NOT IN에 NULL이 섞인 서브쿼리
아래 틀린 코드는 1008번 주문 때문에 book_id가 NULL인 행이 섞여 있어, NOT IN이 아무 행도 반환하지 않는다.
-- 틀린 코드: 결과가 항상 0행이 된다
SELECT b.book_id, b.title
FROM book b
WHERE b.book_id NOT IN (
SELECT od.book_id
FROM orders o
LEFT JOIN order_detail od ON od.order_id = o.order_id
);
-- 고친 코드: NOT EXISTS는 NULL의 영향을 받지 않는다
SELECT b.book_id, b.title
FROM book b
WHERE NOT EXISTS (
SELECT 1 FROM order_detail od WHERE od.book_id = b.book_id
);
조인 팬아웃으로 인한 집계 중복
회원 한 명에 주문과 리뷰를 동시에 조인하면 행이 곱해져 COUNT가 실제보다 커진다.
-- 틀린 코드: 회원1은 주문2건 x 리뷰2건 = 4행이 되어 두 건수 모두 4로 나온다
SELECT m.member_id, COUNT(o.order_id) AS 주문건수, COUNT(r.review_id) AS 리뷰건수
FROM member m
JOIN orders o ON o.member_id = m.member_id
JOIN review r ON r.member_id = m.member_id
GROUP BY m.member_id;
-- 고친 코드: DISTINCT로 곱해진 행을 되돌린다
SELECT m.member_id, COUNT(DISTINCT o.order_id) AS 주문건수, COUNT(DISTINCT r.review_id) AS 리뷰건수
FROM member m
JOIN orders o ON o.member_id = m.member_id
JOIN review r ON r.member_id = m.member_id
GROUP BY m.member_id;
ROLLUP 소계 행을 상세 데이터로 착각
카테고리 소계 행은 grade만 NULL이고 category는 값이 남아 있어, category만 걸러서는 소계가 제거되지 않는다.
-- 틀린 코드: 카테고리 소계 행(grade=NULL)이 그대로 남는다
SELECT b.category, m.grade, SUM(od.quantity * od.unit_price) AS total_sales
FROM orders o
JOIN member m ON m.member_id = o.member_id
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
GROUP BY ROLLUP(b.category, m.grade)
HAVING b.category IS NOT NULL;
-- 고친 코드: GROUPING으로 소계·총계 행을 명시적으로 제외한다
SELECT b.category, m.grade, SUM(od.quantity * od.unit_price) AS total_sales
FROM orders o
JOIN member m ON m.member_id = o.member_id
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
GROUP BY ROLLUP(b.category, m.grade)
HAVING GROUPING(b.category) = 0 AND GROUPING(m.grade) = 0;
필요 없는 조합까지 만드는 CUBE 남용
카테고리별 합계와 등급별 합계만 필요한데 CUBE를 쓰면 카테고리와 등급의 모든 조합까지 함께 나와 해석이 번거로워진다.
-- 틀린 코드: 필요 없는 category x grade 조합까지 모두 나온다
SELECT b.category, m.grade, SUM(od.quantity * od.unit_price) AS total_sales
FROM orders o
JOIN member m ON m.member_id = o.member_id
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
GROUP BY CUBE(b.category, m.grade);
-- 고친 코드: 필요한 두 조합만 GROUPING SETS로 지정한다
SELECT b.category, m.grade, SUM(od.quantity * od.unit_price) AS total_sales
FROM orders o
JOIN member m ON m.member_id = o.member_id
JOIN order_detail od ON od.order_id = o.order_id
JOIN book b ON b.book_id = od.book_id
GROUP BY GROUPING SETS ((b.category), (m.grade));
한눈에 보기
| 점검 항목 | 확인할 것 |
|---|---|
| NULL이 들어갈 수 있는 서브쿼리인가 | NOT IN 대신 NOT EXISTS로 바꿔도 결과가 같은지 확인한다 |
| 두 테이블을 동시에 조인해 집계하는가 | COUNT나 SUM 앞에 DISTINCT가 필요한지 확인한다 |
| ROLLUP·CUBE 결과를 그대로 쓰는가 | GROUPING 함수로 소계·총계 행을 구분했는지 확인한다 |
| 윈도 함수 프레임을 지정했는가 | ROWS와 RANGE 중 무엇을 의도했는지 명시했는지 확인한다 |
| 삭제·갱신 제약을 요구사항과 맞췄는가 | CASCADE를 습관적으로 쓰지 않았는지 확인한다 |
연습 문제
- member와 orders 테이블로 "결제완료 주문이 한 번도 없는 회원" 목록을 NOT EXISTS로 작성하고, 왜 NOT IN보다 안전한지 서술하라.
- order_detail과 book을 조인해 카테고리별 판매 도서 종수를 구하는 쿼리를 작성하고, COUNT(*) 대신 무엇을 써야 하는지 설명하라.
- GROUPING SETS를 이용해 회원 등급별 합계와 전체 합계만 구하고 카테고리별 합계는 제외하는 쿼리를 작성하라.
- 재귀 CTE와 CONNECT BY 중 MySQL 8에서 쓸 수 있는 것은 무엇이며 그 이유는 무엇인가.
정답과 해설
-
SELECT m.member_id, m.name FROM member m WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.member_id = m.member_id AND o.status = '결제완료' );NOT IN은 서브쿼리 결과에 NULL이 하나라도 있으면 전체 조건이 거짓이 되어 결과가 사라진다. NOT EXISTS는 행 단위로 존재 여부만 확인하므로 NULL의 영향을 받지 않는다. 본문 표본 데이터에서는 모든 회원이 결제완료 주문을 한 건 이상 가지고 있어 결과가 0행이며, 이 역시 정상적인 정답이다.
-
SELECT b.category, COUNT(DISTINCT od.book_id) AS 판매도서종수 FROM order_detail od JOIN book b ON b.book_id = od.book_id GROUP BY b.category;COUNT(*)는 주문상세 행 수를 세므로 같은 도서를 여러 주문에서 팔았을 때 중복으로 잡힌다. COUNT(DISTINCT book_id)로 세어야 실제 판매된 도서 종수가 나온다. 표본 데이터로는 IT전문서 3종, 소설 2종, 에세이 1종이다.
-
SELECT m.grade, SUM(od.quantity * od.unit_price) AS total_sales FROM orders o JOIN member m ON m.member_id = o.member_id JOIN order_detail od ON od.order_id = o.order_id WHERE o.status = '결제완료' GROUP BY GROUPING SETS ((m.grade), ());GROUPING SETS에 카테고리를 넣지 않고 (등급)과 빈 집합 ()만 지정하면 등급별 소계와 전체 총계만 나온다. 표본 데이터로는 VIP 156,000원, 일반 134,000원, 총계 290,000원이 나와 앞서 구한 전체 매출과 일치한다.
-
MySQL 8은 WITH RECURSIVE로 재귀 공통테이블식(Recursive CTE)을 지원하지만 CONNECT BY 구문은 지원하지 않는다. CONNECT BY는 오라클 고유의 계층 질의 확장이고, 표준 SQL과 MySQL은 재귀 CTE로 같은 기능을 표현한다.