CSV 세 개를 표 세 개로 — 데이터를 넣는다
Article
파일 세 개로 시작한다. 어떤 쇼핑몰의 고객 · 상품 · 구매이력이다.
- ai-eng-svc/data
- customers.csv
- products.csv
- purchases.csv
1. 데이터를 받는다
파일 세 개를 내려받는 것부터 한다. 작업 폴더에서 아래 파일을 만들어 실행한다.
# 실습에 쓸 데이터를 인터넷에서 받아온다.
# urllib 은 파이썬에 기본으로 들어 있는 인터넷 도구다. 설치할 것이 없다
import urllib.request
# Path 는 폴더와 파일 경로를 다루는 도구다. 이것도 기본으로 들어 있다
from pathlib import Path
# raw.githubusercontent.com 은 깃허브에 올라간 파일의 "날것"을 주는 주소다.
# 브라우저용 꾸밈 없이 파일 내용만 그대로 온다
base = "https://raw.githubusercontent.com/tubi55/dcodelab-class-data/main/"
# data 라는 폴더를 만든다. exist_ok=True 는 "이미 있으면 그냥 넘어가라"는 뜻이다.
# 이게 없으면 두 번째 실행할 때 에러가 난다
Path("data").mkdir(exist_ok=True)
for name in ["customers.csv", "products.csv", "purchases.csv"]:
# urlretrieve 는 주소의 파일을 그대로 받아 저장한다.
# 앞이 받아올 주소, 뒤가 저장할 자리다
urllib.request.urlretrieve(base + name, f"data/{name}")
print("받음:", name)
- 받음: customers.csv
- 받음: products.csv
- 받음: purchases.csv
2. 먼저 열어본다
무엇이 들었는지 모르고 데이터베이스에 넣으면 안 된다. 한 줄씩만 찍어본다.
# csv 는 파이썬에 기본으로 들어 있는 도구다. 따로 설치할 게 없다.
# 쉼표로 나뉜 파일을 알아서 칸별로 잘라준다
import csv
for name in ["customers.csv", "products.csv", "purchases.csv"]:
# open 은 파일을 연다. encoding="utf-8" 은 "한글이 든 파일이다" 라고 알려주는 것이다.
# 이걸 안 적으면 윈도우에서 한글이 깨지거나 에러가 난다.
# with 로 열면 블록이 끝날 때 파이썬이 알아서 파일을 닫아준다
with open(f"data/{name}", encoding="utf-8") as f:
# DictReader 는 첫 줄을 "칸 이름"으로 보고, 나머지 줄을 딕셔너리로 만들어준다.
# {'customer_id': 'C001', 'name': '박은수', ...}
# 그냥 두면 한 줄씩 흘려보내는 물건이라, list() 로 감싸 전부 꺼내 담는다
rows = list(csv.DictReader(f))
print("==", name, len(rows), "줄") # len 은 개수를 센다
print("칸 이름:", list(rows[0])) # 표[0] = 첫 줄. list() 로 감싸면 칸 이름만 나온다
print("첫 줄 :", rows[0])
print() # 빈 줄 하나
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
import csv | csv 도구를 꺼내 쓰겠다는 선언 | 파이썬에 이미 들어 있어 설치가 필요 없다 |
encoding="utf-8" | 한글을 다루는 방식의 이름 | 안 적으면 윈도우에서 글자가 깨진다 |
with open(...) as f | 파일을 열고 끝나면 자동으로 닫는다 | 닫는 것을 잊어도 안전하다 |
DictReader | 한 줄을 딕셔너리로 만들어주는 것 | 줄["name"] 처럼 칸 이름으로 꺼내려고 |
f"data/{파일}" | 글자 안에 변수를 끼워넣는 문법 | data/customers.csv 가 만들어진다 |
표[0] | 목록의 첫 번째 | 파이썬은 0부터 센다 |
- == customers.csv 300 줄
- 칸 이름: ['customer_id', 'name', 'gender', 'age', 'skin_type', 'phone', 'email', 'city', 'joined_at']
- 첫 줄 : {'customer_id': 'C001', 'name': '박은수', 'age': '53', 'skin_type': '건성', 'phone': '010-4786-2358', ...}
- == products.csv 200 줄
- 칸 이름: ['product_id', 'name', 'brand', 'category', 'price', 'volume', 'skin_type', 'ingredient', 'concern', 'tags', 'description']
- == purchases.csv 1500 줄
- 칸 이름: ['purchase_id', 'customer_id', 'product_id', 'purchased_at', 'quantity', 'rating', 'review', 'is_holdout']
여기서 세 가지가 벌써 보인다.
| 본 것 | 무슨 뜻인가 |
|---|---|
phone · email 이 있다 | 개인정보다. 8·9편을 통째로 여기에 쓴다 |
age 가 '53' 이다 (따옴표) | CSV 에서 읽으면 숫자도 글자로 들어온다. 그대로 두면 정렬이 이상해진다 |
is_holdout 이라는 낯선 칸 | 오늘의 함정이다. 뒤에서 따로 본다 |
3. 왜 파일이 셋인가
한 파일에 다 넣으면 안 되나? 넣어보면 왜 안 되는지 바로 보인다.
표 하나로
구매할 때마다 한 줄
- 박은수 · 010-4786-2358 · 성남 · 토너 · 24000
- 박은수 · 010-4786-2358 · 성남 · 크림 · 33000
- 박은수 · 010-4786-2358 · 성남 · 세럼 · 36000
- 전화번호가 살 때마다 반복된다
표 셋으로
지금 받은 모양
- 고객표에 박은수 한 줄
- 상품표에 토너 한 줄
- 구매표에는 번호 두 개만 —
C001·P012 - 전화번호를 고치면 한 군데만 고친다
4. SQLite — 설치할 게 없다
데이터베이스라고 하면 뭔가 깔아야 할 것 같은데, 오늘 쓸 것은 파이썬에 이미 들어 있다.
- 설치
- 없다
- 파이썬에 들어 있다
- 서버
- 없다
- 켜고 끌 것이 없다
- 저장
- 파일 하나
- shop.db
5. 표 셋을 만든다
import csv
import sqlite3 # 데이터베이스 도구. 이것도 파이썬에 기본으로 들어 있다
# connect 는 데이터베이스 파일에 연결한다.
# 그 이름의 파일이 없으면 새로 만들고, 있으면 그냥 연다.
# con(connection) = "데이터베이스와 연결된 통로" 라고 생각하면 된다
con = sqlite3.connect("shop.db")
# cur(cursor) = "명령을 대신 실행해주고 결과를 받아오는 손" 이다.
# 통로(con)로는 명령을 못 보내고, 반드시 이 손(cur)을 통해 보낸다.
# 왜 나뉘어 있냐면 — 통로 하나로 여러 손이 각자 다른 조회를 할 수 있어서다
cur = con.cursor()
# execute 는 SQL 문장 하나만 실행한다. 여기서는 표를 셋 만들어야 하므로
# 여러 문장을 한 번에 실행하는 executescript 를 쓴다.
# 큰따옴표 세 개(""")로 감싸면 여러 줄짜리 글자를 그대로 적을 수 있다.
# 아래 -- 로 시작하는 줄은 SQL 의 주석이다 (파이썬의 # 과 같은 역할)
cur.executescript("""
CREATE TABLE customers (
customer_id TEXT PRIMARY KEY, -- TEXT=글자 · PRIMARY KEY=이 칸이 이 표의 이름표다.
-- 같은 값이 두 번 들어오면 데이터베이스가 막아준다
name TEXT,
gender TEXT,
age INTEGER, -- 숫자로 넣는다. 나중에 나이로 거르려면 필요하다
skin_type TEXT,
phone TEXT,
email TEXT,
city TEXT,
joined_at TEXT
);
CREATE TABLE products (
product_id TEXT PRIMARY KEY,
name TEXT,
brand TEXT,
category TEXT,
price INTEGER, -- 가격도 숫자다. 3만원 이하를 세려면 반드시
volume TEXT,
skin_type TEXT,
ingredient TEXT,
concern TEXT,
tags TEXT,
description TEXT -- 다음 편에서 이 글을 숫자로 바꾼다
);
CREATE TABLE purchases (
purchase_id TEXT PRIMARY KEY,
customer_id TEXT, -- 고객표의 번호를 가리킨다
product_id TEXT, -- 상품표의 번호를 가리킨다
purchased_at TEXT,
quantity INTEGER,
rating INTEGER,
review TEXT,
is_holdout INTEGER -- 오늘의 함정. 뒤에서 본다
);
""")
6. CSV 를 부어 넣는다
def load(table, name):
"""CSV 파일 하나를 읽어서 표 하나에 통째로 넣는다.
table 은 넣을 표 이름, name 은 읽을 파일 이름이다.
"""
# 앞에서 열어볼 때와 똑같다. encoding 을 적어야 한글이 안 깨지고,
# with 로 열면 블록이 끝날 때 파이썬이 알아서 닫아준다
with open(f"data/{name}", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
# 칸 이름 목록을 뽑는다. 첫 줄의 열쇠들이 곧 칸 이름이다
cols = list(rows[0]) # ['customer_id', 'name', 'gender', ...]
# 파이썬에서는 글자에 곱하기를 하면 그만큼 반복된다. "?" * 3 -> "???"
# join 은 목록을 정해진 글자로 이어붙인다. ",".join("???") -> "?,?,?"
# 칸이 9개면 "?,?,?,?,?,?,?,?,?" 가 만들어진다 — 값이 들어갈 빈자리다
marks = ",".join("?" * len(cols))
# 넣을 값을 줄마다 하나씩 준비한다. 아래 한 줄을 풀어 쓰면 이렇다 ─
# values = []
# for row in rows:
# values.append(tuple(row[c] for c in cols))
# tuple 은 "순서가 정해진 값 묶음"이다. 물음표 순서와 짝이 맞아야 해서 목록이 아니라
# 칸 순서대로 꺼낸 묶음으로 만든다
values = [tuple(row[c] for c in cols) for row in rows]
# execute 는 한 줄씩, executemany 는 여러 줄을 한 번에 넣는다.
# 1,500번을 하나씩 보내는 것보다 훨씬 빠르다
cur.executemany(
f"INSERT INTO {table} VALUES ({marks})",
values,
)
# {table:10s} 는 "글자를 10칸 폭으로 왼쪽 정렬", {len(rows):5d} 는 "숫자를 5칸 폭으로"
print(f"{table:10s} {len(rows):5d}줄")
load("customers", "customers.csv")
load("products", "products.csv")
load("purchases", "purchases.csv")
# 지금까지 넣은 것을 파일에 확정해서 새긴다. 이걸 빼먹으면 아무것도 안 남는다
con.commit()
# 다 썼으면 연결을 닫는다
con.close()
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
con (connection) | 데이터베이스로 통하는 길 | 파일 하나에 연결한다 |
cur (cursor) | 명령을 대신 보내고 결과를 받아오는 손 | 조회는 전부 이걸로 한다 |
"?" * 9 | 글자를 9번 반복 | 칸 수만큼 빈자리를 만든다 |
",".join(...) | 목록을 쉼표로 이어붙이기 | ?,?,? 를 만든다 |
tuple | 순서가 고정된 값 묶음 | 물음표 자리와 순서를 맞추려고 |
executemany | 여러 줄을 한 번에 넣기 | 1,500번 왕복을 한 번으로 |
commit() | 파일에 확정해서 새긴다 | 빼먹으면 텅 빈 파일이 된다 |
- customers 300줄
- products 200줄
- purchases 1500줄
이번 편에 나온 것
| 한 것 | 내용 |
|---|---|
csv.DictReader | 첫 줄을 칸 이름으로 보고 딕셔너리로 만든다 |
encoding="utf-8" | 안 적으면 한글이 깨진다 |
| 표를 셋으로 나눈 이유 | 한 고객이 여러 번 산다. 반복되면 뗀다 |
| SQLite | 설치 없음 · 서버 없음 · 파일 하나 |
con 과 cur | 통로와, 명령을 대신 보내는 손 |
CREATE TABLE | 칸 이름과 종류를 정한다. 숫자는 INTEGER 로 |
"?" * 9 · join | 값이 들어갈 빈자리를 칸 수만큼 만든다 |
executemany | 1,500줄을 한 번에 넣는다 |
commit() | 이걸 빼면 파일에 안 남는다 |
| 물음표 자리표시 | 값을 이어붙이지 않는다. 따옴표·쉼표에 안 깨진다 |
is_holdout | 낯선 칸 하나. 다음 편에서 본다 |
미션
- 01
[필수] 세 표가 다 들어갔는지 세어본다
mission_db_1.py. 표마다SELECT COUNT(*)를 찍어 300 · 200 · 1500 이 나오는지 본다. 숫자가 다르면 어디서 어긋났는지 찾는다. - 02
[응용] 일부러 두 번 돌려본다
mission_db_2.py.03_load_db.py를 한 번 더 실행하면 어떤 에러가 나나. 에러 메시지를 그대로 적어두면 나중에 같은 걸 만났을 때 바로 알아본다. - 03
[도전] commit 을 빼고 돌려본다
mission_db_3.py.con.commit()을 지우고 넣은 뒤, 다른 파일에서 표를 세어본다. 몇 줄이 나오나. 왜 그런지 한 줄로 적는다.
[도전] commit 을 빼면 몇 줄이 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
0 줄이 나온다. 넣었다는 메시지는 다 나왔는데 파일에는 아무것도 없다.
넣기와 저장이 나뉘어 있기 때문이다. 여러 줄을 넣다가 중간에 잘못되면 통째로 없던 일로 되돌릴 수 있게 하려고 이렇게 만들어져 있다.
그래서 commit() 은 「지금까지 넣은 것을 확정한다」는 뜻이다. 반대로 되돌리는 것은 rollback() 이다.
같은 프로그램 안에서는 보인다는 것이 함정이다. 넣고 바로 세어보면 1,500 이 나온다. 그래서 다른 파일에서 확인해봐야 알아챈다.
import sqlite3
con = sqlite3.connect("shop.db")
cur = con.cursor()
# commit 을 안 한 뒤 다른 파일에서 세어보면 0 이 나온다
for rows in ("customers", "products", "purchases"):
n = cur.execute(f"SELECT COUNT(*) FROM {rows}").fetchone()[0]
print(rows, n)실무에서는 이 성질을 일부러 쓴다. 돈을 옮기는 일처럼 여러 줄이 전부 되거나 전부 안 되어야 하는 경우다.
지금은 그냥 「빼먹으면 안 남는다」만 기억하면 된다. 매 기수에 꼭 한 명은 여기서 30분을 쓴다.
정리하면
아무것도 설치하지 않고 데이터베이스를 만들었다. SQLite 는 파이썬에 이미 들어 있고, 결과물은 폴더에 생긴 shop.db 파일 하나다.
표를 셋으로 나눈 이유는 하나다 — 한 고객이 여러 번 산다. 한 표에 다 넣으면 전화번호가 살 때마다 반복된다. 어렵게 말하면 정규화지만, 지금은 「반복되면 뗀다」로 충분하다.
그리고 값을 넣을 때 물음표 자리에 넘겼다. 후기 안에 따옴표와 쉼표가 들어 있어서 글자로 이어붙이면 깨지기 때문이다. 습관으로 굳혀두면 나중에 사고 하나를 안 낸다.
마지막으로 commit() 이 있었다. 이 한 줄을 빼면 에러도 없이 텅 빈 파일이 남는다.
다음 편에서 꺼내본다. 네 가지 모양만 알면 이 시리즈에 필요한 건 다 된다 — 고르고, 이어붙이고, 묶어 세고, 숫자로 거른다. 그리고 is_holdout 이라는 낯선 칸의 정체도 거기서 밝힌다.