학습 자료

CSV 세 개를 표 세 개로 — 데이터를 넣는다


Article

파일 세 개로 시작한다. 어떤 쇼핑몰의 고객 · 상품 · 구매이력이다.

받게 될 파일 셋
  • ai-eng-svc/data
    • customers.csv300줄 · 24KB — 누가 사는가
    • products.csv200줄 · 88KB — 무엇을 파는가
    • purchases.csv1,500줄 · 206KB — 누가 무엇을 샀고 뭐라고 썼나

1. 데이터를 받는다

파일 세 개를 내려받는 것부터 한다. 작업 폴더에서 아래 파일을 만들어 실행한다.

01_download.py
# 실습에 쓸 데이터를 인터넷에서 받아온다.
# 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. 먼저 열어본다

무엇이 들었는지 모르고 데이터베이스에 넣으면 안 된다. 한 줄씩만 찍어본다.

01_peek.py
# 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 csvcsv 도구를 꺼내 쓰겠다는 선언파이썬에 이미 들어 있어 설치가 필요 없다
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 — 설치할 게 없다

데이터베이스라고 하면 뭔가 깔아야 할 것 같은데, 오늘 쓸 것은 파이썬에 이미 들어 있다.

SQLite 가 다른 데이터베이스와 다른 점
설치
없다
파이썬에 들어 있다
서버
없다
켜고 끌 것이 없다
저장
파일 하나
shop.db

5. 표 셋을 만든다

02_load_db.py
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 를 부어 넣는다

02_load_db.py — 이어서
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값이 들어갈 빈자리를 칸 수만큼 만든다
executemany1,500줄을 한 번에 넣는다
commit()이걸 빼면 파일에 안 남는다
물음표 자리표시값을 이어붙이지 않는다. 따옴표·쉼표에 안 깨진다
is_holdout낯선 칸 하나. 다음 편에서 본다

미션

미션
  1. 01

    [필수] 세 표가 다 들어갔는지 세어본다

    mission_db_1.py. 표마다 SELECT COUNT(*) 를 찍어 300 · 200 · 1500 이 나오는지 본다. 숫자가 다르면 어디서 어긋났는지 찾는다.

  2. 02

    [응용] 일부러 두 번 돌려본다

    mission_db_2.py. 03_load_db.py 를 한 번 더 실행하면 어떤 에러가 나나. 에러 메시지를 그대로 적어두면 나중에 같은 걸 만났을 때 바로 알아본다.

  3. 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 이라는 낯선 칸의 정체도 거기서 밝힌다.

Share
  • 파이썬
  • SQL
  • SQLite
  • 데이터
CSV 세 개를 표 세 개로 — 데이터를 넣는다 — 디코드랩(DCODELAB)