15 SQL SubQuery 활용 실습
1. 인트로
SubQuery(서브쿼리)는 SQL 안에 또 다른 SQL을 넣는 기술이다. 단일 SELECT로 처리하기 어려운 복잡한 조건을 하나의 쿼리로 해결할 수 있다. 이번 차시에서는 WHERE, FROM, SELECT 절에서의 서브쿼리 사용법을 모두 익힌다.
2. 실습 DB 준비
1import sqlite32import pandas as pd3
4conn = sqlite3.connect("streaming.db")5cur = conn.cursor()6
7# 사용자 테이블8cur.execute("""9 CREATE TABLE IF NOT EXISTS users (10 id INTEGER PRIMARY KEY AUTOINCREMENT,11 name TEXT NOT NULL,12 plan TEXT DEFAULT 'basic',13 join_dt TEXT DEFAULT (date('now'))14 )15""")16
17# 콘텐츠 테이블18cur.execute("""19 CREATE TABLE IF NOT EXISTS contents (20 id INTEGER PRIMARY KEY AUTOINCREMENT,21 title TEXT NOT NULL,22 genre TEXT,23 rating REAL24 )25""")26
27# 시청 기록 테이블28cur.execute("""29 CREATE TABLE IF NOT EXISTS watches (30 id INTEGER PRIMARY KEY AUTOINCREMENT,31 user_id INTEGER,32 content_id INTEGER,33 watch_dt TEXT DEFAULT (date('now')),34 FOREIGN KEY (user_id) REFERENCES users(id),35 FOREIGN KEY (content_id) REFERENCES contents(id)36 )37""")38
39conn.commit()40
41# 데이터 삽입42users_data = [43 ("김철수", "premium"), ("이영희", "basic"),44 ("박지민", "premium"), ("최수진", "basic"),45 ("홍길동", "standard"),("강민준", "premium"),46]47cur.executemany("INSERT INTO users (name,plan) VALUES (?,?)", users_data)48
49contents_data = [50 ("오징어게임", "드라마", 9.5),51 ("더글로리", "드라마", 9.2),52 ("이상한변호사우영우", "드라마", 9.0),53 ("정이", "SF", 7.8),54 ("수리남", "스릴러", 8.5),55 ("D.P.", "드라마", 8.8),56]57cur.executemany("INSERT INTO contents (title,genre,rating) VALUES (?,?,?)", contents_data)58
59watches_data = [60 (1,1),(1,2),(1,3),(1,5),61 (2,1),(2,4),62 (3,1),(3,2),(3,3),(3,4),(3,5),(3,6),63 (4,2),64 (5,1),(5,3),65 (6,1),(6,2),(6,5),(6,6),66]67cur.executemany("INSERT INTO watches (user_id,content_id) VALUES (?,?)", watches_data)68
69conn.commit()70print("스트리밍 DB 준비 완료")3. WHERE 절에서의 SubQuery 활용법
3.1. 평균보다 높은 평점의 콘텐츠
1df = pd.read_sql("""2 SELECT title, rating3 FROM contents4 WHERE rating > (SELECT AVG(rating) FROM contents)5 ORDER BY rating DESC6""", conn)7print("=== 평균 이상 평점 콘텐츠 ===")8print(df)코드 설명:
- 4행:
(SELECT AVG(rating) FROM contents)— 전체 콘텐츠의 평균 평점을 구한다. - 바깥 WHERE에서 그 값보다 높은 콘텐츠만 필터링한다.
3.2. 가장 많이 시청된 콘텐츠 조회
1cur.execute("""2 SELECT title, rating3 FROM contents4 WHERE id = (5 SELECT content_id6 FROM watches7 GROUP BY content_id8 ORDER BY COUNT(*) DESC9 LIMIT 110 )11""")12top = cur.fetchone()13print(f"최다 시청 콘텐츠: {top[0]} (평점: {top[1]})")코드 설명:
- 5~9행: 서브쿼리에서 watches 테이블을 content_id로 그룹화하여 가장 많이 시청된 content_id를 구한다.
- 바깥 쿼리에서 그 id와 일치하는 콘텐츠 정보를 가져온다.
3.3. IN + SubQuery 조합
1# premium 사용자가 시청한 콘텐츠 목록2df = pd.read_sql("""3 SELECT DISTINCT c.title, c.genre4 FROM contents AS c5 WHERE c.id IN (6 SELECT w.content_id7 FROM watches AS w8 INNER JOIN users AS u ON w.user_id = u.id9 WHERE u.plan = 'premium'10 )11 ORDER BY c.title12""", conn)13print("=== 프리미엄 사용자 시청 콘텐츠 ===")14print(df)코드 설명:
- 5~10행: 서브쿼리에서 premium 사용자가 시청한 content_id 목록을 구한다.
- 3행:
DISTINCT— 중복 없이 고유한 콘텐츠만 가져온다. - 바깥 WHERE IN에서 그 목록에 포함된 콘텐츠만 가져온다.
4. FROM 절에서의 SubQuery 활용법
FROM 절에 서브쿼리를 넣으면 결과를 임시 테이블(Derived Table(더라이브드 테이블))처럼 사용할 수 있다.
4.1. 사용자별 시청 수 집계 후 필터링
1df = pd.read_sql("""2 SELECT name, plan, watch_cnt3 FROM (4 SELECT u.name, u.plan, COUNT(w.id) AS watch_cnt5 FROM users AS u6 LEFT JOIN watches AS w ON u.id = w.user_id7 GROUP BY u.id, u.name, u.plan8 ) AS user_stats9 WHERE watch_cnt >= 310 ORDER BY watch_cnt DESC11""", conn)12print("=== 시청 3개 이상 사용자 ===")13print(df)코드 설명:
- 3~8행: FROM 절 안의 서브쿼리로 사용자별 시청 수를 집계한다.
- 8행:
AS user_stats— 서브쿼리 결과에 이름을 붙인다. (필수) - 9행: 집계 결과인 watch_cnt로 필터링한다. HAVING 대신 WHERE를 사용할 수 있다.
5. SubQuery(서브쿼리) 심화 실습
5.1. 상관 서브쿼리(Correlated SubQuery(코릴레이티드 서브쿼리))
바깥 쿼리의 각 행마다 서브쿼리가 실행되는 패턴이다.
1# 각 장르의 평균보다 높은 평점의 콘텐츠2df = pd.read_sql("""3 SELECT c1.title, c1.genre, c1.rating4 FROM contents AS c15 WHERE c1.rating > (6 SELECT AVG(c2.rating)7 FROM contents AS c28 WHERE c2.genre = c1.genre9 )10 ORDER BY c1.genre, c1.rating DESC11""", conn)12print("=== 장르 내 평균 초과 콘텐츠 ===")13print(df)코드 설명:
- 5~8행: 서브쿼리 안에서
c2.genre = c1.genre로 바깥 쿼리의 행(c1)을 참조한다. - 바깥 쿼리의 각 행(c1)마다 서브쿼리가 실행되어 해당 장르의 평균을 계산한다.
- c1의 평점이 그 장르 평균보다 높은 경우만 결과에 포함한다.
5.2. EXISTS(이그지스츠)를 사용한 서브쿼리
1# 시청 기록이 있는 사용자만 조회2df = pd.read_sql("""3 SELECT u.name, u.plan4 FROM users AS u5 WHERE EXISTS (6 SELECT 17 FROM watches AS w8 WHERE w.user_id = u.id9 )10""", conn)11print("=== 시청 기록 있는 사용자 ===")12print(df)코드 설명:
- 5행:
EXISTS— 서브쿼리의 결과가 한 행 이상 존재하면 TRUE를 반환한다. - 6행:
SELECT 1— 실제 값보다 존재 여부만 확인하므로1을 사용한다. 성능상 효율적이다. NOT EXISTS— 반대로 결과가 없는 경우에 TRUE를 반환한다.
6. 활용 Plus — 리액트 추천 시스템 연동
리액트 스트리밍 앱의 “내가 아직 안 본 콘텐츠 추천” 기능 API 예시이다.
1def get_recommendations(user_id):2 # 사용자가 아직 시청하지 않은 콘텐츠 중 평점 상위 3개 추천3 cur.execute("""4 SELECT title, genre, rating5 FROM contents6 WHERE id NOT IN (7 SELECT content_id8 FROM watches9 WHERE user_id = ?10 )11 ORDER BY rating DESC12 LIMIT 313 """, (user_id,))14 rows = cur.fetchall()15 return [{"title": r[0], "genre": r[1], "rating": r[2]} for r in rows]16
17recs = get_recommendations(user_id=2)18print("=== user_id=2 추천 콘텐츠 ===")19for r in recs:20 print(r)코드 설명:
- 6~9행: 서브쿼리로 user_id=2가 이미 시청한 content_id 목록을 구한다.
- 5행:
NOT IN— 그 목록에 없는 콘텐츠만 가져온다. 즉, 아직 안 본 콘텐츠이다. - 11행:
LIMIT 3— 평점 높은 순으로 3개만 추천한다.
7. 문제풀기
WHERE 절 서브쿼리: 조건값으로 사용된다. 단일 값 또는 값의 목록을 반환한다. FROM 절 서브쿼리: 임시 테이블처럼 사용된다. 반드시 별칭(AS)을 붙여야 한다.
다른 테이블의 조건을 만족하는 id 목록을 구하고, 그 목록 안의 데이터를 찾을 때 유용하다. 예: “특정 조건의 사용자가 시청한 콘텐츠 목록 조회”
IN: 서브쿼리가 반환하는 값의 목록과 비교한다. EXISTS: 서브쿼리의 결과가 존재하는지(한 행이라도 있는지)만 확인한다. 대용량 데이터에서는 EXISTS가 더 빠른 경우가 많다.
일반 서브쿼리: 한 번만 실행되고 결과를 바깥 쿼리에 전달한다. 상관 서브쿼리: 바깥 쿼리의 각 행마다 서브쿼리가 한 번씩 실행된다. 바깥 쿼리의 컬럼을 서브쿼리 안에서 참조한다.