학습 자료

SQL 로 꺼내본다 — 그리고 데이터 안에 정답이 섞여 있다


Article

앞 편에서 CSV 세 개를 표 세 개에 넣었다. 이제 꺼내는 법을 배운다.

1. 꺼내본다

네 가지 모양만 알면 이 시리즈에 필요한 건 다 된다.

① 고객 한 명 — WHERE

03_query.py
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)
터미널 — C007 의 구매 이력
  • ('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
채점하는 방법
  1. 마지막 구매를 가린다is_holdout = 1
  2. 나머지만 주고 추천시킨다is_holdout = 0
  3. 가린 것이 목록에 있나맞았다 / 틀렸다
우리는 답을 안다. 이미 산 물건이니까. 정답을 아는 문제를 만드는 것이 이 수업의 습관이다.
# 앞으로 이력을 꺼낼 때는 항상 이 조건을 붙인다.
#
# 여기서 물음표가 또 나왔다. 값을 글자로 이어붙이지 않고 물음표 자리에 넘기는 방식이다.
# 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()
C007 로 보는 두 가지

모델에게 줄 것

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편 재료

미션

미션
  1. 01

    [필수] 내 고객 한 명을 정해 이력을 뽑는다

    mission_db_1.py. C007 말고 아무 번호나 고른다. 정답은 빼고 이력만 뽑아 상품 이름·값·별점을 찍는다. 그 사람 취향이 눈에 보이는지 한 줄로 적는다.

  2. 02

    [응용] 「많이 팔렸는데 평점이 낮은」 상품을 찾는다

    mission_db_2.py. GROUP BY 에 HAVING 을 붙이면 묶은 뒤에 거를 수 있다. 판매 10건 이상이면서 평균 평점 3.5 이하인 상품을 뽑아본다.

  3. 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 로 찾으면 그 낱말이 그대로 든 상품만 걸리기 때문이다. 뜻으로 고르려면 다른 도구가 필요하다.

Share
  • 파이썬
  • SQL
  • SQLite
  • 데이터
  • 추천