18강 / 20강

졸업 작품 — 진짜 쇼핑몰 만들기

SQLite3 · Flask · Jinja2 · Bootstrap을 한 채의 집으로

졸업 작품 — 진짜 쇼핑몰 만들기
상품 · 장바구니 · 주문 — 세 개의 표가 외래키로 이어집니다.

드디어 마지막입니다. 지금까지 배운 전부를 써서 작동하는 쇼핑몰을 만듭니다.

쇼핑몰은 화려한 화면처럼 보이지만, 사실은 데이터베이스 위에 세운 집입니다. 상품·장바구니·주문이라는 표 세 개가 진짜 알맹이고, 나머지 세 층은 그 표를 사람이 쓸 수 있게 만들어 줄 뿐이에요.

4층🎨 Bootstrap

카드·격자·뱃지 — 보기 좋고 휴대폰에서도 편하게

3층📄 Jinja2

{% for p in products %} — 행 하나가 카드 한 장

2층🧪 Flask

라우트·검증·트랜잭션 — 판단은 전부 여기서

1층🗄️ SQLite3

product · cart · orders — 유일한 진실

진짜 프로젝트의 파일 구조

실무의 Flask 프로젝트도 정확히 이 모양입니다. 파일 위치만 봐도 어느 층의 일인지 알 수 있어요.

myshop/
├── app.py                  ← 2층 : 라우트 · 검증 · 트랜잭션 (파이썬)
├── shop.db                 ← 1층 : 데이터베이스 파일 그 자체
├── templates/              ← 3층 : Jinja2 템플릿
│   ├── base.html               공통 껍데기 (navbar · footer)
│   ├── products.html           상품 목록  {% extends "base.html" %}
│   ├── cart.html               장바구니
│   └── orders.html             주문 내역
└── static/                 ← 4층 : 브라우저가 그대로 받아 가는 파일
    ├── css/style.css           Bootstrap 위에 얹는 우리 색깔
    └── img/                    상품 사진

이제 첫 삽을 뜹니다. 가장 먼저 할 일은 코딩이 아니라 설계예요. "무엇에 대한 정보를 저장할 것인가"를 나누는 일이죠.

🛍️ product (상품)
id · INTEGER 🔑
name · TEXT
price · INTEGER
stock · INTEGER
1 : N
상품 하나가
여러 번 담길 수 있다
🛒 cart (장바구니)
id · INTEGER 🔑
product_id · INTEGER 🔗
qty · INTEGER
→
주문하면
옮겨진다
🧾 orders (주문)
id · INTEGER 🔑
total · INTEGER
created · TEXT

이런 그림을 ERD(개체-관계 다이어그램)라고 부릅니다. 실무에서 개발자들은 코드를 한 줄도 쓰기 전에 이 그림부터 그려요.

🎮 진짜 쇼핑몰 — 직접 주문해 보세요

아래는 이 서버의 SQLite3에 실제로 저장되는 쇼핑몰입니다. 상품을 담고 주문하면 진짜 레코드가 만들어지고, 재고가 실제로 줄어듭니다.

모든 클릭마다 서버가 실행한 진짜 SQL이 아래 로그에 나타납니다. 버튼을 누르면서 SQL이 어떻게 바뀌는지 관찰해 보세요.

미니 쇼핑몰 — Flask + SQLite3로 실제 동작합니다
불러오는 중…

서버는 무슨 일을 했을까? — 주문 버튼의 내부

여러분이 주문하기를 눌렀을 때, 서버에서는 이런 일이 벌어집니다. 11강의 트랜잭션이 여기서 진가를 발휘해요.

@app.route("/api/shop/order", methods=["POST"])
def place_order():
    conn = db()
    try:
        conn.execute("BEGIN")                       # ① 트랜잭션 시작

        # ② JOIN으로 장바구니 + 상품 정보를 함께 읽는다
        items = conn.execute('''
            SELECT c.id, c.qty, p.id AS pid, p.name, p.price, p.stock
            FROM cart c JOIN product p ON c.product_id = p.id
        ''').fetchall()

        if not items:
            raise ValueError("장바구니가 비어 있습니다")

        # ③ 재고 검사 — 서버가 DB를 보고 직접 확인한다!
        for it in items:
            if it["qty"] > it["stock"]:
                raise ValueError(f"{it['name']} 재고 부족")

        # ④ 가격도 서버가 DB에서 다시 계산한다 (브라우저 값을 안 믿는다)
        total = sum(it["price"] * it["qty"] for it in items)

        # ⑤ 주문 생성 + 재고 차감 + 장바구니 비우기
        cur = conn.execute(
            "INSERT INTO orders (total, created) VALUES (?, datetime('now'))", (total,))
        for it in items:
            conn.execute("UPDATE product SET stock = stock - ? WHERE id = ?",
                         (it["qty"], it["pid"]))
        conn.execute("DELETE FROM cart")

        conn.commit()                               # ⑥ 전부 성공 → 확정
        return jsonify({"ok": True, "total": total})

    except Exception as e:
        conn.rollback()                             # ⑦ 하나라도 실패 → 전부 취소
        return jsonify({"ok": False, "error": str(e)}), 400

그 데이터가 화면이 되는 곳 — 3층과 4층

위 함수는 주문을 DB에 기록했습니다. 그럼 사용자는 그 결과를 어떻게 볼까요? 2층이 SELECT 결과를 3층에 넘기고, 3층이 4층의 부품으로 화면을 찍어 냅니다.

# ── app.py (2층) — 주문 내역 페이지 ──────────────
@app.route("/orders")
def order_list():
    conn = db()
    orders = conn.execute('''
        SELECT o.id, o.total, o.created, COUNT(i.id) AS 품목수
        FROM orders o LEFT JOIN order_item i ON i.order_id = o.id
        GROUP BY o.id
        ORDER BY o.id DESC
    ''').fetchall()
    conn.close()
    return render_template("orders.html", orders=orders)
<!-- ── templates/orders.html (3층 + 4층) ────────── -->
{% extends "base.html" %}
{% block title %}주문 내역{% endblock %}

{% block content %}
<h1 class="mb-4">🧾 주문 내역</h1>

{% if orders %}
  <table class="table table-striped align-middle">
    <thead><tr><th>주문번호</th><th>품목</th><th class="text-end">결제금액</th><th>주문시각</th></tr></thead>
    <tbody>
      {% for o in orders %}
      <tr>
        <td><span class="badge bg-secondary">#{{ o.id }}</span></td>
        <td>{{ o.품목수 }}종</td>
        <td class="text-end fw-bold">{{ "{:,}".format(o.total) }}원</td>
        <td class="text-secondary">{{ o.created }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
{% else %}
  <div class="alert alert-info">아직 주문이 없습니다. 🛒</div>
{% endif %}
{% endblock %}

주목할 점 — 이 템플릿에는 SQL도, 가격 계산도 없습니다. 반대로 app.py 에는 <table> 태그가 한 개도 없습니다. 각 층이 자기 일만 하고 있다는 증거예요. 이 규율이 프로그램을 오래 살아남게 합니다.

🔍 데이터가 진짜로 들어갔는지 확인하기

말로만 믿을 수 없죠. 위 쇼핑몰에서 주문을 한 번 한 뒤, 아래 SQL을 실행해 여러분의 주문 기록을 직접 조회해 보세요.

서버 DB에 실제로 저장된 내 주문.
SQL 브라우저에서 실행
UPDATE 로 차감된 재고를 눈으로 확인.
SQL 브라우저에서 실행

🎓 여기까지의 여정을 되돌아봅시다

1-2값에 이름을 붙이고, 묶었습니다
3-5한 줄이 레코드, 모으면 테이블이 되었습니다
6-8표들을 캐비닛에 넣고, 인덱스로 빠르게, 타입으로 안전하게
9-11Todo 앱을 만들고, 표를 나눠 잇고, 사서(DBMS)를 만났습니다
12-13브라우저의 한계를 보고 서버로 넘어가, SQLite3를 열었습니다 [1층]
14Flask 라우트로 요청을 받고 판단했습니다 [2층]
15Jinja2로 DB의 행을 화면의 줄로 바꿨습니다 [3층]
16Bootstrap으로 어디서나 보기 좋은 화면을 만들었습니다 [4층]
17-18폼으로 입력을 받아 DB에 넣고, 네 층을 쌓아 쇼핑몰을 완성했습니다

여러분이 지금 할 수 있는 일

🗄️설계

무엇을 표로 나눌지 정하고 ERD를 그릴 수 있습니다

🧪서버

Flask 라우트로 요청을 받아 SQL을 실행할 수 있습니다

📄화면

Jinja2 + Bootstrap으로 데이터를 화면으로 바꿀 수 있습니다

🔒안전

자리표시자·서버 검증·트랜잭션으로 데이터를 지킬 수 있습니다

다음은 무엇을 배울까요?

SQL 더 깊이

서브쿼리, 윈도우 함수, 뷰(VIEW), 트리거

다른 DBMS

PostgreSQL·MySQL로 옮겨 보기. SQL은 거의 그대로 통합니다

ORM

SQLAlchemy — 파이썬 객체로 DB를 다루는 방법

NoSQL

MongoDB·Redis — 표가 아닌 방식의 데이터베이스

실전 프로젝트

동아리 출석부, 급식 평점 앱, 문제집 오답노트를 직접 만들어 보세요

여기까지 온 여러분, 정말 수고했습니다. 🎉
이제 여러분은 데이터를 설계하고, 저장하고, 보여 주는 법을 압니다.
세상의 거의 모든 서비스가 이 네 층 위에 서 있습니다.

그런데 딱 하나가 비어 있습니다 — 주문 표에 "누가 주문했는지"가 없어요. 지금 이 쇼핑몰은 모두가 장바구니 하나를 함께 쓰는 가게입니다.
마지막 19강에서 회원·로그인·세션을 붙여 진짜 서비스로 완성합니다. 👉

🤔 확인 문제
쇼핑몰에서 결제 금액을 계산할 때 가장 안전한 방법은?