회원과 로그인 — '누구의 데이터인가'
심화편 · 세션과 비밀번호를 다루는 법
지난 강에서 쇼핑몰을 완성했습니다. 그런데 주문 표를 다시 들여다보면 빠진 것이 하나 있어요.
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에 진짜로 생깁니다.
해시 — 한쪽으로만 가는 문
같은 사과를 넣으면 언제나 같은 주스가 나옵니다. 그래서 로그인 검사가 가능해요. 하지만 주스만 보고 사과를 복원할 수는 없습니다. 이것이 일방향 함수입니다.
여기에 한 가지를 더합니다. 소금(salt) — 사람마다 다른 무작위 문자열을 섞는 거예요. 소금이 없으면 비밀번호가 같은 두 사람의 해시가 똑같아져서, 공격자가 미리 만들어 둔 표로 한꺼번에 뚫을 수 있습니다.
세션 — 웹은 원래 기억력이 없습니다
HTTP에는 이상한 성질이 있습니다. 요청 하나하나가 서로 남남이에요. 방금 로그인한 사람이 다음 순간 상품 목록을 열어도, 서버는 그가 누구인지 전혀 모릅니다. 이것을 무상태(stateless)라고 합니다.
그래서 놀이공원의 방법을 씁니다 — 입구에서 한 번 확인하고 손목에 도장을 찍어 주는 거죠. 그 뒤로는 도장만 보여 주면 됩니다. 그 도장이 세션 쿠키입니다.
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 표가 만들어지고,
로그인 상태는 진짜 세션 쿠키에 담깁니다. 이렇게 해 보세요.
- 가입해 보세요. 그리고 member 표를 보세요 — 비밀번호는 어디에도 없습니다.
- 비밀번호를 7자로 넣어 가입해 보세요. 서버가 거절합니다.
- 이미 있는 아이디(
minjun)로 가입해 보세요. UNIQUE가 막습니다. - 로그인한 뒤 이 페이지를 새로고침해 보세요. 로그인 상태가 유지됩니다 — 쿠키 덕분이에요.
- 주문을 하나 만들고, 로그아웃한 뒤 다시
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로 확인해 봅시다
주문 표에 주인을 달아 주고, 로그인한 사람의 주문만 골라내 봅니다. 차이가 한눈에 보일 거예요.
🎓 앱이 완성되었습니다
19강 동안 우리는 변수 한 개에서 출발해 회원이 로그인해 자기 주문을 확인하는 쇼핑몰까지 왔습니다.
로그인 상태에 따라 달라지는 화면
{% if g.user %} — 조건에 따른 화면
세션 · 데코레이터 · 인가 검사
member 표 · UNIQUE · 외래키
여러분은 이제 데이터를 설계하고, 저장하고, 보여 주고, 지키는 법을 압니다. 세상의 거의 모든 서비스가 정확히 이 네 층 위에 서 있습니다.
그런데 이 훌륭한 앱은 아직 내 컴퓨터에서만 돌아갑니다.
주소가 127.0.0.1:5000이니까요. 친구는 볼 수 없습니다.
마지막 20강에서 도메인 · HTTPS · NGINX · Cloudflare 터널로
집에 있는 컴퓨터 한 대를 진짜 인터넷 서비스로 만듭니다. 👉
/orders/12 를 /orders/13 으로 바꿨습니다. 13번은 다른 사람의 주문입니다. 서버는 어떻게 막아야 할까요?AND user_id = ? 를 넣어 서버가 소유자를 확인해야 합니다. 화면을 감추거나 브라우저에서 막는 것은 우회됩니다.check_password_hash()로 대조만 합니다. 그래서 진짜 서비스가 '비밀번호 찾기'가 아니라 '재설정'을 시키는 거예요.