SQL 로 꺼내본다 — 그리고 데이터 안에 정답이 섞여 있다
Article
앞 편에서 CSV 세 개를 표 세 개에 넣었다. 이제 꺼내는 법을 배운다.
1. 꺼내본다
네 가지 모양만 알면 이 시리즈에 필요한 건 다 된다.
① 고객 한 명 — WHERE
import sqlite3
con = sqlite3.connect("shop.db") # 아까 만든 파일을 연다
cur = con.cursor()
# SELECT 꺼낼 칸 이름들 (별표 * 를 쓰면 전부)
# FROM 어느 표에서
# WHERE 어떤 조건인 줄만
#
# execute 는 명령을 보내기만 한다. 결과를 받으려면 아래 둘 중 하나를 붙여야 한다 ─
# fetchone() 첫 줄 하나만 -> ('C001', '박은수', 53, ...)
# fetchall() 걸린 줄 전부 -> [(...), (...), ...]
print(cur.execute("""
SELECT customer_id, name, age, skin_type, city
FROM customers
WHERE customer_id = 'C001'
""").fetchone())
- ('C001', '박은수', 53, '건성', '성남')
② 두 표를 이어붙인다 — JOIN
구매표에는 P106 같은 번호만 있다. 사람이 읽을 이름은 상품표에 있다. 둘을 이어야 뜻이 생긴다.
# 두 표를 붙이면 같은 이름의 칸이 양쪽에 있을 수 있다 (여기서는 name 이 둘 다 있다).
# 그래서 "어느 표의 칸인지" 를 표 이름을 앞에 붙여 밝힌다 — products.name 처럼.
# 헷갈릴 일이 없으면 생략해도 되지만, 붙여두는 편이 읽기 쉽다
rows = cur.execute("""
SELECT purchases.purchased_at, products.name, products.price, purchases.rating, purchases.is_holdout
FROM purchases
JOIN products ON purchases.product_id = products.product_id -- 두 표에서 번호가 같은 줄끼리 붙인다
WHERE purchases.customer_id = 'C007'
ORDER BY purchases.purchased_at -- 날짜 오름차순 (오래된 것부터)
""").fetchall() # 걸린 줄 전부를 목록으로 받는다
for r in rows: # r 은 줄 하나. ('2025-09-16', '병풀 링클 클렌징오일', 28100, 3, 0)
print(r)
- ('2025-09-16', '병풀 링클 클렌징오일', 28100, 3, 0)
- ('2025-11-29', '히알루론산 탄력 클렌징오일', 25000, 4, 0)
- ('2026-05-29', '쌀겨 탄력 선크림', 19500, 5, 0)
- ('2026-06-07', '히알루론산 시카 클렌징오일', 23200, 4, 1)
③ 세면서 묶는다 — GROUP BY
# GROUP BY 는 같은 값끼리 한 덩어리로 묶는다. 묶고 나면 덩어리마다 하나씩 계산할 수 있다 ─
# COUNT(*) 그 덩어리에 줄이 몇 개인가 (* 는 "줄 자체를 센다")
# AVG(칸) 그 칸의 평균
# ROUND(값, 2) 소수점 둘째 자리까지 반올림
# AS 는 계산 결과에 이름을 붙이는 것이다. 이름이 있어야 아래 ORDER BY 에서 가리킬 수 있다
for r in cur.execute("""
SELECT products.name, COUNT(*) AS n_sold, ROUND(AVG(purchases.rating), 2) AS avg_rating
FROM purchases
JOIN products ON purchases.product_id = products.product_id
GROUP BY products.product_id -- 상품별로 묶는다
ORDER BY n_sold DESC -- DESC = 큰 것부터 (ASC 는 작은 것부터, 안 적으면 ASC)
LIMIT 5 -- 위에서 다섯 줄만
""").fetchall():
print(r)
- ('쌀겨 탄력 선크림', 20, 4.0)
- ('레티놀 브라이트닝 클렌징오일', 19, 4.11)
- ('레티놀 탄력 아이크림', 16, 4.31)
- ('알로에 브라이트닝 에센스', 15, 3.47)
- ('약산성 아미노산 포어 세럼', 15, 3.53)
④ 숫자로 거른다 — 뒤에서 다시 만난다
# fetchone() 은 줄 하나를 묶음으로 준다. 칸이 하나뿐이어도 (80,) 처럼 온다.
# 숫자만 필요하면 뒤에 [0] 을 붙여 첫 칸을 꺼낸다
print(cur.execute("SELECT COUNT(*) FROM products WHERE price <= 30000").fetchone())
# AND 는 두 조건을 모두 만족하는 줄만 남긴다 (하나만 만족해도 되면 OR)
# LIKE 는 글자가 들어 있는지 보는 것이고, % 는 "여기에 아무 글자나 와도 된다" 는 뜻이다.
# '%선물용%' -> 앞뒤에 뭐가 붙든 선물용 이라는 글자가 들어 있으면 걸린다
for r in cur.execute("""
SELECT name, brand, price FROM products
WHERE price <= 30000 AND tags LIKE '%선물용%'
ORDER BY price DESC
LIMIT 5
""").fetchall():
print(r)
- (80,)
- ('히알루론산 트러블 토너', '데일리랩', 29900)
- ('나이아신아마이드 포어 클렌징오일', '데일리랩', 29600)
- ('비타민C 시카 미스트', '청담수', 29100)
- ('알로에 트러블 세럼', '데일리랩', 29100)
- ('나이아신아마이드 수분 아이크림', '바른결', 29100)
2. 🔴 데이터 안에 정답이 섞여 있다
is_holdout 을 이제 본다. 이게 오늘 제일 중요한 이야기다.
# [0] 을 붙인 이유는 위에서 본 그대로다 — (1500,) 에서 숫자만 꺼낸다
print("전체", cur.execute("SELECT COUNT(*) FROM purchases").fetchone()[0])
print("숨김", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 1").fetchone()[0])
print("이력", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 0").fetchone()[0])
- 전체 1500
- 숨김 300
- 이력 1200
- 마지막 구매를 가린다is_holdout = 1
- 나머지만 주고 추천시킨다is_holdout = 0
- 가린 것이 목록에 있나맞았다 / 틀렸다
# 앞으로 이력을 꺼낼 때는 항상 이 조건을 붙인다.
#
# 여기서 물음표가 또 나왔다. 값을 글자로 이어붙이지 않고 물음표 자리에 넘기는 방식이다.
# execute 의 두 번째 자리에 값 묶음을 준다 — ("C007",)
# 쉼표가 붙은 이유는, 값이 하나일 때 ("C007") 이라고 쓰면 파이썬이 그냥 글자로 보기 때문이다.
# 쉼표를 찍어야 "값이 하나 든 묶음"이 된다
rows = cur.execute("""
SELECT products.name, products.category, products.price, purchases.rating, purchases.review
FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ?
AND purchases.is_holdout = 0 -- 정답은 빼고 가져온다
ORDER BY purchases.purchased_at
""", ("C007",)).fetchall()
모델에게 줄 것
is_holdout = 0 · 세 줄
- 병풀 링클 클렌징오일 28,100
- 히알루론산 탄력 클렌징오일 25,000
- 쌀겨 탄력 선크림 19,500
우리만 아는 정답
is_holdout = 1 · 한 줄
- 히알루론산 시카 클렌징오일 23,200
- 추천 목록에 이게 들어 있으면 맞은 것
- 끝까지 모델에게 안 보여준다
3. 후기를 훑어본다 — 8편 예고
마지막으로 후기를 몇 줄만 읽어본다.
# 후기에 010- 이 들어 있는 줄을 세 개만 꺼내본다
for r in cur.execute("SELECT customer_id, review FROM purchases WHERE review LIKE '%010-%' LIMIT 3").fetchall():
print(r)
# 하이픈 없이 010 만 들어 있는 것까지 세면 몇 건인가
print(cur.execute("SELECT COUNT(*) FROM purchases WHERE review LIKE '%010%'").fetchone()[0])
- ('C004', '가격 생각하면 이 정도는 하는 것 같아요. 중성인데 자극 없이 잘 썼어요. 궁금하신 분은 010-7600-2955으로 문자주세요.')
- ('C010', '샘플 써보고 본품 구매했어요. 중성인데 자극 없이 잘 썼어요. 궁금하신 분은 010-9367-6259으로 문자주세요.')
- ('C012', '고민하다 샀는데 잘 산 것 같아요. 탱탱한 느낌이 오래간다는 게 느껴져요. 궁금하신 분은 010-2982-1557으로 문자주세요.')
- 152
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
SELECT · FROM | 무엇을 · 어디서 |
WHERE | 고른다 |
fetchone() / fetchall() | 첫 줄 하나 / 걸린 줄 전부 |
JOIN ... ON | 번호를 사람이 읽을 이름으로 바꾼다 |
products.name | 어느 표의 칸인지 앞에 밝힌다 |
GROUP BY · COUNT · AVG | 묶어서 센다 |
ORDER BY ... DESC · LIMIT | 큰 것부터 · 몇 줄만 |
LIKE '%선물용%' | 글자가 들어 있나. % 는 아무 글자나 |
price <= 30000 | 뒤에서 임베딩이 못 하는 것 |
🔴 is_holdout | 정답이 데이터에 섞여 있다. 이력을 꺼낼 땐 항상 = 0 |
| C007 의 이력 | 클렌징오일 셋 · 히알루론산 둘 · 2만원대 |
| 후기 152건 | 전화번호가 들어 있다. 8편 재료 |
미션
- 01
[필수] 내 고객 한 명을 정해 이력을 뽑는다
mission_db_1.py.C007말고 아무 번호나 고른다. 정답은 빼고 이력만 뽑아 상품 이름·값·별점을 찍는다. 그 사람 취향이 눈에 보이는지 한 줄로 적는다. - 02
[응용] 「많이 팔렸는데 평점이 낮은」 상품을 찾는다
mission_db_2.py.GROUP BY에HAVING을 붙이면 묶은 뒤에 거를 수 있다. 판매 10건 이상이면서 평균 평점 3.5 이하인 상품을 뽑아본다. - 03
[도전] 정답이 이력과 얼마나 닮았는지 센다
mission_db_3.py. 고객 300명 전부에 대해 숨긴 상품의 카테고리가 이력에도 있었는지 센다. 이 숫자가 내일 우리가 넘어야 할 벽이다.
[도전] 맞힐 수 있는 문제인지 미리 재본다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
300명 중 174명(58%) 은 숨긴 상품의 카테고리를 이미 사본 적이 있다.
이게 무슨 뜻인가 — 카테고리만 잘 맞혀도 절반 넘게는 후보 안에 들어온다. 반대로 말하면 나머지 42%는 안 사본 종류를 샀다는 뜻이라 이력만으로는 못 맞힌다.
추천을 만들기 전에 이 숫자를 아는 것이 중요하다. 이걸 모르면 나중에 정확도가 30% 나왔을 때 그게 잘한 건지 못한 건지 판단할 수가 없다.
이런 걸 천장이라고 부른다. 천장을 모르고 튜닝하면 될 리 없는 것을 붙잡고 하루를 쓴다.
import collections
hit = 0
customer_ids = [r[0] for r in cur.execute("SELECT customer_id FROM customers").fetchall()]
for cid in customer_ids:
history = cur.execute("""
SELECT products.category FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
""", (cid,)).fetchall()
answer = cur.execute("""
SELECT products.category FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 1
""", (cid,)).fetchone()
if answer[0] in {c[0] for c in history}:
hit += 1
print(f"{hit}/{len(customer_ids)} = {hit / len(customer_ids) * 100:.1f}%")58% 는 카테고리 하나만 봤을 때의 숫자다. 성분·브랜드·가격대까지 같이 보면 후보를 더 좁힐 수 있다.
그리고 이 58% 도 천장은 아니다. 안 사본 종류를 사는 사람도 후기나 피부 타입을 보면 짐작할 여지가 있다. 거기가 모델이 사람보다 나을 수 있는 자리다.
정리하면
네 가지 모양을 배웠다. 고르고(WHERE), 이어붙이고(JOIN), 묶어 세고(GROUP BY), 글자로 거른다(LIKE). 이 시리즈에서 데이터를 꺼내는 일은 전부 이 조합이다.
그리고 두 가지를 눈으로 봤다.
하나는 C007 의 이력에 취향이 보인다는 것이다. 클렌징오일 셋, 히알루론산 둘, 2만원대. 사람 눈에 보이면 맞힐 수 있는 문제라는 뜻이고, 실제로 그 사람이 마지막에 산 것도 2만원대 히알루론산 클렌징오일이었다.
다른 하나는 정답이 데이터 안에 섞여 있다는 것이다. is_holdout 이 1인 300줄이 채점의 답안지다. 이력을 꺼낼 때 이 조건을 빼먹으면 정확도가 갑자기 아주 좋아지는데, 좋아 보이는 숫자가 나오면 먼저 의심하는 것이 이 시리즈의 습관이다.
마지막으로 WHERE price <= 30000 을 눈여겨둘 것. 뒤에서 뜻으로 찾는 방법이 이걸 못 해서 쩔쩔매는 장면이 나온다. 그때 이 한 줄이 답이 된다.
다음 편에서 문장을 숫자로 바꾼다. 지금은 숫자와 정해진 낱말로만 고를 수 있다. 「건조한 피부에 순한 것」 같은 말로는 못 고른다 — LIKE 로 찾으면 그 낱말이 그대로 든 상품만 걸리기 때문이다. 뜻으로 고르려면 다른 도구가 필요하다.