깔아 두기만 2회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록

shop.py 코드 리뷰

결론: ❌ 머지 불가 (Request Changes)

실제 결제·재고가 걸린 서비스에 그대로 올리면 고객 정보 유출, 무료 주문/잔고 조작, 서버 파일 탈취가 모두 가능합니다. 아래 🔴 Critical 항목은 전부 실제로 재현했고, 고치기 전까지는 배포하면 안 됩니다.

# 심각도 위치 문제 재현
1 🔴 Critical shop.py:7 운영(live) 관리자 토큰이 소스에 하드코딩 코드 확인
2 🔴 Critical shop.py:19 SQL 인젝션 ✅ 사용자 비밀번호·잔고 추출
3 🔴 Critical shop.py:77-80 경로 조작(Path Traversal)으로 임의 파일 읽기 ✅ /etc/hosts, 소스코드 읽힘
4 🔴 Critical shop.py:40-43 남의 주문 조회 가능 (IDOR, 권한 검사 없음) ✅
5 🔴 Critical shop.py:46-66 수량 음수/0 허용 → 재고·잔고 조작 ✅ 잔고 0 → 100,000
6 🔴 Critical shop.py:61-64 잔고 부족 검사 없음 ✅ 잔고 0에서 주문 성공
7 🔴 Critical shop.py:48-55 동시 주문 시 초과 판매(경쟁 조건) ✅ 재고 1 → -1
8 🟠 High shop.py:25 페이지네이션 off-by-one: 1페이지가 비어 보임 ✅
9 🟠 High shop.py:69-74 쿠폰 오류를 삼켜 None 반환, 100% 초과 할인 허용 ✅ -500.0
10 🟠 High shop.py:51 없는 상품 주문 시 TypeError로 크래시 ✅
11 🟡 Medium shop.py:11-14 외 DB 연결을 한 번도 닫지 않음, 예외 시 롤백 없음 코드 확인
12 🟡 Medium shop.py:31-36 N+1 쿼리 코드 확인
13 🟢 Low 여러 곳 정렬 없음, SELECT *, 테스트 없음 등 코드 확인

🔴 Critical

1. 운영 관리자 토큰 하드코딩 — shop.py:7, shop.py:83-84

ADMIN_TOKEN = "sk_live_9f8e7d6c5b4a3210shopadmin"

수정:

import hmac, os
ADMIN_TOKEN = os.environ["SHOP_ADMIN_TOKEN"]  # 또는 시크릿 매니저

def is_admin(token):
    return isinstance(token, str) and hmac.compare_digest(token, ADMIN_TOKEN)

2. SQL 인젝션 — shop.py:19

sql = f"SELECT id, name, price FROM products WHERE name LIKE '%{keyword}%'"

검색어를 문자열에 그대로 끼워 넣습니다. 재현 결과, 검색창에 아래 값을 넣으면 users 테이블의 비밀번호와 잔고가 상품 목록처럼 반환됩니다.

입력: %' UNION SELECT id,password,balance FROM users --
결과: [{'name': 'apple', ...}, {'id': 1, 'name': 'pw1', 'price': 0}, {'id': 2, 'name': 'secret2', 'price': 50000}]

같은 방식으로 DB 전체를 읽을 수 있습니다. 다른 함수들은 ? 파라미터를 잘 쓰고 있는데 여기만 빠졌습니다.

수정:

sql = "SELECT id, name, price FROM products WHERE name LIKE ? ESCAPE '\\'"
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return [dict(r) for r in db.execute(sql, (f"%{escaped}%",)).fetchall()]

3. 경로 조작으로 서버의 임의 파일 읽기 — shop.py:77-80

path = os.path.join(INVOICE_DIR, filename)

수정: 파일명을 사용자 입력으로 받지 말고 order_id로 받아 소유자를 확인한 뒤 서버에서 경로를 만드세요. 꼭 파일명을 받아야 한다면 최종 경로가 디렉터리 안인지 검증합니다.

from pathlib import Path
base = Path(INVOICE_DIR).resolve()
path = (base / filename).resolve()
if not path.is_relative_to(base):
    raise PermissionError("invalid invoice path")

4. 남의 주문 조회 가능 (IDOR) — shop.py:40-43

current_user_id를 인자로 받기만 하고 쓰지 않습니다. 재현 결과 사용자 1이 사용자 2의 주문을 그대로 조회했습니다. 주문 ID는 1, 2, 3… 순차 증가라 전체 주문을 쉽게 긁어갈 수 있습니다.

수정:

row = db.execute(
    "SELECT id, user_id, created_at FROM orders WHERE id = ? AND user_id = ?",
    (order_id, current_user_id),
).fetchone()

5. 수량 음수/0 허용 → 재고·잔고 조작 — shop.py:46-66

qty 검증이 없습니다. 재현 결과 place_order(user, 1, -100) 한 번으로:

외부에서 수량을 조작할 수 있는 API라면 바로 돈을 찍어낼 수 있습니다.

수정: 함수 시작부에서 if not isinstance(qty, int) or qty <= 0: raise ValueError(...) (필요하면 최대 수량 상한도).

6. 잔고 부족 검사 없음 — shop.py:61-64

재고만 확인하고 잔고는 확인하지 않아, 잔고가 모자라도 주문이 성공하고 잔고가 마이너스가 됩니다. 재현 시 잔고 0원인 사용자의 3,000원 주문이 그대로 처리됐습니다(5번 결과와 합쳐 100,000 → 97,000).

수정: 7번처럼 조건부 UPDATE로 처리하는 것을 권장합니다.

cur = db.execute(
    "UPDATE users SET balance = balance - ? WHERE id = ? AND balance >= ?",
    (total, user_id, total),
)
if cur.rowcount == 0:
    raise ValueError("잔고 부족")

DB 레벨에서도 CHECK (balance >= 0), CHECK (stock >= 0) 제약을 걸어두면 안전망이 됩니다.

7. 동시 주문 시 초과 판매 (경쟁 조건) — shop.py:48-55

"재고 SELECT → 파이썬에서 비교 → UPDATE"가 하나의 원자적 단위가 아닙니다. sqlite3 기본 모드에서는 SELECT 시점에 트랜잭션이 시작되지 않으므로, 두 요청이 동시에 "재고 1개 남음"을 읽고 둘 다 통과합니다.

재현: 재고 1개 상품에 동시 주문 2건 → 둘 다 성공, 재고 -1.

수정: 확인과 차감을 한 문장으로 묶고, 전체 주문을 하나의 트랜잭션으로 처리합니다.

def place_order(user_id, product_id, qty):
    if not isinstance(qty, int) or qty <= 0:
        raise ValueError("잘못된 수량")
    with closing(get_db()) as db, db:          # with db: 성공 시 commit, 예외 시 rollback
        db.execute("BEGIN IMMEDIATE")
        product = db.execute(
            "SELECT price FROM products WHERE id = ?", (product_id,)
        ).fetchone()
        if product is None:
            raise LookupError("상품 없음")
        total = product["price"] * qty

        cur = db.execute(
            "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
            (qty, product_id, qty),
        )
        if cur.rowcount == 0:
            raise ValueError("재고 부족")
        cur = db.execute(
            "UPDATE users SET balance = balance - ? WHERE id = ? AND balance >= ?",
            (total, user_id, total),
        )
        if cur.rowcount == 0:
            raise ValueError("잔고 부족")

        order_id = db.execute(
            "INSERT INTO orders (user_id) VALUES (?)", (user_id,)
        ).lastrowid
        db.execute(
            "INSERT INTO order_items (order_id, product_id, qty, price) VALUES (?, ?, ?, ?)",
            (order_id, product_id, qty, product["price"]),
        )
        return order_id

(BEGIN IMMEDIATE를 쓰려면 sqlite3.connect(DB_PATH, isolation_level=None)로 수동 트랜잭션 모드를 쓰거나, Python 3.12+의 autocommit 설정을 맞춰야 합니다. 조건부 UPDATE만으로도 초과 판매 자체는 막힙니다.)


🟠 High

8. 페이지네이션 off-by-one — shop.py:25

offset = page * PAGE_SIZE   # page=1 → offset 20

기본값 page=1인데 첫 20건을 건너뜁니다. 재현 시 주문이 있는 사용자의 list_orders(user, 1)이 빈 리스트를 반환했습니다. 고객 입장에선 "내 주문이 사라졌다"로 보입니다.

수정: offset = (max(page, 1) - 1) * PAGE_SIZE

9. 쿠폰 처리 — shop.py:69-74

수정 예:

def apply_coupon(total, percent):
    if not 0 <= percent <= 100:
        raise ValueError("잘못된 할인율")
    return total - (total * percent // 100)

10. 없는 상품 주문 시 크래시 — shop.py:51

fetchone()이 None이면 stock["stock"]에서 TypeError: 'NoneType' object is not subscriptable이 발생합니다(재현). 500 에러로 노출되고 원인 파악도 어렵습니다. 7번 수정 코드처럼 None 체크 후 명확한 예외를 던지세요.


🟡 Medium

11. DB 연결 누수 / 예외 시 롤백 없음 — shop.py:11-14 및 모든 함수

12. N+1 쿼리 — shop.py:31-36

주문 20건을 보여주려고 쿼리를 21번 실행합니다. JOIN 한 번이나 WHERE order_id IN (...) 한 번으로 묶으세요.


🟢 Low / 개선 제안


머지 전 체크리스트

위 Critical·High 항목이 반영되면 다시 한 번 리뷰하는 것을 권장합니다. 이번 주 배포 일정이 빠듯하다면, 최소한 1~7번은 배포 차단 조건(blocker)으로 두세요.