19강 / 20강

회원과 로그인 — '누구의 데이터인가'

심화편 · 세션과 비밀번호를 다루는 법

회원과 로그인 — '누구의 데이터인가'
로그인은 손목 도장입니다. 한 번 확인받고, 그 뒤로는 도장을 보여 주죠.

지난 강에서 쇼핑몰을 완성했습니다. 그런데 주문 표를 다시 들여다보면 빠진 것이 하나 있어요.

SELECT * FROM orders;

id | total  | created
 1 | 71000  | 2026-08-20 14:02
 2 | 12000  | 2026-08-20 14:07

누가 주문했는지가 없습니다. 지금 이 쇼핑몰은 세상 사람 모두가 하나의 장바구니를 함께 쓰는 이상한 가게예요. 내 주문을 남이 보고, 남의 주문을 내가 봅니다.

고칠 방법은 이미 배웠습니다. 표를 하나 더 만들고 외래키로 잇는 것(10강), 그리고 서버가 판단하는 것(18강). 여기에 새로운 개념 하나, 세션이 더해집니다.

1층 — 회원 표를 설계합니다

8강에서 배운 제약 조건이 여기서 전부 쓰입니다. 특히 UNIQUE — 같은 아이디로 두 명이 가입하면 로그인이 누구를 가리키는지 알 수 없으니까요.

CREATE TABLE member (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,        -- 아이디는 중복 불가!
    pw_hash  TEXT NOT NULL,               -- 비밀번호가 '아니라' 해시
    role     TEXT NOT NULL DEFAULT 'user',-- user | admin
    created  TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);

-- 주문이 '누구의 것'인지 이어 준다 (10강의 외래키)
ALTER TABLE orders ADD COLUMN user_id INTEGER REFERENCES member(id);

아래 SQL을 직접 실행해 회원 표를 만들어 보세요. 여러분의 개인 연습 DB에 진짜로 생깁니다.

pw_hash 칸을 보세요 — 비밀번호가 어디에도 보이지 않습니다.
SQL 브라우저에서 실행
일부러 실패시켜 보는 셀 — DBMS가 규칙을 지켜 주는 순간입니다.
SQL 브라우저에서 실행

해시 — 한쪽으로만 가는 문

🍎
비밀번호
사과
🌀
해시 함수
믹서기
🥤
해시값
주스 — 사과로 되돌릴 수 없다

같은 사과를 넣으면 언제나 같은 주스가 나옵니다. 그래서 로그인 검사가 가능해요. 하지만 주스만 보고 사과를 복원할 수는 없습니다. 이것이 일방향 함수입니다.

여기에 한 가지를 더합니다. 소금(salt) — 사람마다 다른 무작위 문자열을 섞는 거예요. 소금이 없으면 비밀번호가 같은 두 사람의 해시가 똑같아져서, 공격자가 미리 만들어 둔 표로 한꺼번에 뚫을 수 있습니다.

직접 확인해 보세요. 비밀번호를 바꿔 가며 실행해도 좋습니다.
Python 브라우저에서 실행

세션 — 웹은 원래 기억력이 없습니다

HTTP에는 이상한 성질이 있습니다. 요청 하나하나가 서로 남남이에요. 방금 로그인한 사람이 다음 순간 상품 목록을 열어도, 서버는 그가 누구인지 전혀 모릅니다. 이것을 무상태(stateless)라고 합니다.

그래서 놀이공원의 방법을 씁니다 — 입구에서 한 번 확인하고 손목에 도장을 찍어 주는 거죠. 그 뒤로는 도장만 보여 주면 됩니다. 그 도장이 세션 쿠키입니다.

🔑로그인아이디 · 비밀번호
→
🧪Flask해시 대조 후 session["uid"] = 3
→
🍪쿠키서명된 도장이 브라우저에
← 이후 모든 요청에 자동으로 첨부 ←
from flask import Flask, session, request, redirect, url_for, render_template, flash, g
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]   # 도장의 '위조 방지 잉크'. 코드에 적지 마세요!

# ── 가입 ────────────────────────────────────────
@app.route("/signup", methods=["POST"])
def signup():
    username = request.form["username"].strip()
    password = request.form["password"]

    if len(password) < 8:
        flash("비밀번호는 8자 이상이어야 합니다."); return redirect(url_for("login_page"))

    conn = db()
    try:
        conn.execute("INSERT INTO member (username, pw_hash) VALUES (?, ?)",
                     (username, generate_password_hash(password)))   # ← 해시만 저장
        conn.commit()
    except sqlite3.IntegrityError:                 # UNIQUE 위반 = 이미 있는 아이디
        flash("이미 사용 중인 아이디입니다."); return redirect(url_for("login_page"))
    finally:
        conn.close()
    return redirect(url_for("login_page"))

# ── 로그인 ──────────────────────────────────────
@app.route("/login", methods=["POST"])
def login():
    conn = db()
    row = conn.execute("SELECT * FROM member WHERE username = ?",
                       (request.form["username"],)).fetchone()
    conn.close()

    if row is None or not check_password_hash(row["pw_hash"], request.form["password"]):
        flash("아이디 또는 비밀번호가 올바르지 않습니다.")   # 둘 중 뭐가 틀렸는지 알려주지 않는다
        return redirect(url_for("login_page"))

    session.clear()
    session["uid"] = row["id"]          # ← 손목 도장을 찍는 한 줄
    return redirect(url_for("product_list"))

# ── 로그아웃 ────────────────────────────────────
@app.route("/logout")
def logout():
    session.clear()                     # 도장을 지운다
    return redirect(url_for("product_list"))

🎮 로그인 실습실 — 진짜로 가입하고 로그인해 보세요

아래는 이 서버에서 실제로 동작하는 로그인 실습실입니다. 여러분의 개인 연습 DB에 demo_member 표가 만들어지고, 로그인 상태는 진짜 세션 쿠키에 담깁니다. 이렇게 해 보세요.

  1. 가입해 보세요. 그리고 member 표를 보세요 — 비밀번호는 어디에도 없습니다.
  2. 비밀번호를 7자로 넣어 가입해 보세요. 서버가 거절합니다.
  3. 이미 있는 아이디(minjun)로 가입해 보세요. UNIQUE가 막습니다.
  4. 로그인한 뒤 이 페이지를 새로고침해 보세요. 로그인 상태가 유지됩니다 — 쿠키 덕분이에요.
  5. 주문을 하나 만들고, 로그아웃한 뒤 다시 minjun으로 로그인해 보세요. 내 주문만 보입니다.
로그인 실습실 — 세션 · 해시 · 인가를 직접 만져 봅니다
불러오는 중…

인가 — 문지기를 한 줄로 만들기

로그인한 사람만 들어올 수 있는 페이지가 필요합니다. 라우트마다 if를 쓰면 한 곳만 빠뜨려도 구멍이 되죠. 그래서 데코레이터로 문지기를 하나 만들어 붙입니다.

from functools import wraps

@app.before_request
def load_user():
    g.user = None
    if "uid" in session:
        conn = db()
        g.user = conn.execute("SELECT id, username, role FROM member WHERE id = ?",
                              (session["uid"],)).fetchone()   # 매 요청마다 DB에서 다시 확인
        conn.close()

def login_required(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        if g.user is None:
            flash("로그인이 필요합니다.")
            return redirect(url_for("login_page"))
        return f(*args, **kwargs)
    return wrapper

# ── 내 주문만 보이는 페이지 ──────────────────────
@app.route("/orders")
@login_required                                    # ← 문지기 한 줄
def my_orders():
    conn = db()
    orders = conn.execute(
        "SELECT * FROM orders WHERE user_id = ? ORDER BY id DESC",
        (g.user["id"],)).fetchall()                # ← 인가: 내 것만!
    conn.close()
    return render_template("orders.html", orders=orders)

# ── 주문 상세 — 여기가 진짜 함정입니다 ───────────
@app.route("/orders/<int:oid>")
@login_required
def order_detail(oid):
    conn = db()
    o = conn.execute("SELECT * FROM orders WHERE id = ? AND user_id = ?",
                     (oid, g.user["id"])).fetchone()   # ← 소유자까지 함께 검사
    conn.close()
    if o is None:
        abort(404)                                     # 남의 주문 = 없는 주문
    return render_template("order_detail.html", order=o)

3층 — 화면은 로그인 상태에 따라 달라집니다

15강의 {% if %} 가 여기서 아주 유용해집니다. 템플릿에서는 session 과 g 를 그냥 쓸 수 있어요.

<!-- templates/base.html -->
<nav class="navbar navbar-expand-lg bg-body-tertiary">
  <div class="container">
    <a class="navbar-brand" href="{{ url_for('product_list') }}">🛍️ 우리 쇼핑몰</a>

    {% if g.user %}
      <span class="ms-auto me-3">
        {{ g.user.username }}님
        {% if g.user.role == 'admin' %}<span class="badge bg-danger">관리자</span>{% endif %}
      </span>
      <a class="btn btn-sm btn-outline-secondary" href="{{ url_for('my_orders') }}">내 주문</a>
      <a class="btn btn-sm btn-outline-secondary ms-2" href="{{ url_for('logout') }}">로그아웃</a>
    {% else %}
      <a class="btn btn-sm btn-primary ms-auto" href="{{ url_for('login_page') }}">로그인</a>
    {% endif %}

  </div>
</nav>

단, 착각하면 안 되는 것 — 버튼을 감추는 것은 보안이 아닙니다. 관리자 버튼을 화면에서 지워도, 주소를 직접 입력하면 그만이에요. 화면 숨기기는 친절이고, 진짜 검사는 언제나 서버의 라우트에서 합니다.

🔍 '내 것만 보기'를 SQL로 확인해 봅시다

주문 표에 주인을 달아 주고, 로그인한 사람의 주문만 골라내 봅니다. 차이가 한눈에 보일 거예요.

이것이 '로그인은 시켰지만 인가는 잊은' 화면입니다.
SQL 브라우저에서 실행
WHERE 한 줄이 개인정보를 지킵니다.
SQL 브라우저에서 실행

🎓 앱이 완성되었습니다

19강 동안 우리는 변수 한 개에서 출발해 회원이 로그인해 자기 주문을 확인하는 쇼핑몰까지 왔습니다.

4층🎨 Bootstrap

로그인 상태에 따라 달라지는 화면

3층📄 Jinja2

{% if g.user %} — 조건에 따른 화면

2층🧪 Flask

세션 · 데코레이터 · 인가 검사

1층🗄️ SQLite3

member 표 · UNIQUE · 외래키

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

그런데 이 훌륭한 앱은 아직 내 컴퓨터에서만 돌아갑니다. 주소가 127.0.0.1:5000이니까요. 친구는 볼 수 없습니다.
마지막 20강에서 도메인 · HTTPS · NGINX · Cloudflare 터널로 집에 있는 컴퓨터 한 대를 진짜 인터넷 서비스로 만듭니다. 👉

🤔 확인 문제
로그인한 사용자가 주소창에서 /orders/12 를 /orders/13 으로 바꿨습니다. 13번은 다른 사람의 주문입니다. 서버는 어떻게 막아야 할까요?
🤔 확인 문제
회원 가입 시 비밀번호를 데이터베이스에 어떻게 저장해야 할까요?