/engineering:code-review 2회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록

Code Review: shop.py (쇼핑몰 주문 처리 모듈)

검토일: 2026-10-16 · 대상: shop.py (85줄, sqlite3) 아래 표에 "재현됨"으로 표시한 항목은 임시 DB를 만들어 실제로 실행해 확인했습니다. 실행 결과는 맨 아래 부록에 있습니다.

Summary

코드는 짧고 읽기 쉽고, 대부분의 쿼리에 파라미터 바인딩(?)을 써서 기본은 되어 있습니다. 하지만 돈·재고·개인정보를 다루는 경로에 바로 악용할 수 있는 결함이 여러 개 있습니다. SQL 인젝션으로 비밀번호 해시가 새고, 수량에 음수를 넣으면 잔액이 생기고, 남의 주문과 서버 파일을 읽을 수 있습니다. 이 상태로는 이번 주 실서비스에 올리면 안 됩니다.

Verdict

❌ Request Changes (머지 불가)

배포 전에 반드시 고칠 것: Critical 1~6번, High 7~9번. 고친 뒤에는 아래 "권장 테스트"를 추가하고 다시 리뷰하는 것을 권합니다.


Critical Issues

# Line Issue Severity 재현
1 19 search_products: f-string으로 SQL을 조립해서 SQL 인젝션 발생. 검색어에 ' UNION SELECT id, email, password_hash FROM users --를 넣으면 전체 회원의 이메일과 비밀번호 해시가 상품 목록인 것처럼 반환됨 🔴 Critical ✅
2 7 ADMIN_TOKEN = "sk_live_...": 운영용(live) 비밀키가 소스에 하드코딩되어 있음. 이미 친구와 공유됐으니 유출된 것으로 보고 키 폐기·재발급이 필요함 🔴 Critical —
3 46–66 place_order: qty 검증이 없음. qty=-10이면 재고 검사(5 < -10)를 통과한 뒤 재고가 +10, 잔액이 +10,000 됨. 결국 무한으로 돈을 만들 수 있음 🔴 Critical ✅
4 61–64 place_order: 잔액 확인 없이 차감함. 잔액이 0원인 사용자가 3,000원어치를 주문하면 잔액이 −3,000이 되고 주문은 성공함 🔴 Critical ✅
5 40–43 get_order: current_user_id를 받기만 하고 쓰지 않음. IDOR라서 주문 ID만 바꾸면 남의 주문을 볼 수 있음. 게다가 SELECT *로 모든 컬럼을 그대로 노출함 🔴 Critical ✅
6 77–80 download_invoice: 경로 탐색(Path Traversal) 가능. ../shop.py나 /etc/hosts를 넣으면 서버의 아무 파일이나 읽힘. 절대경로를 넣으면 os.path.join이 INVOICE_DIR을 아예 무시함. 다운로드하는 사람이 그 송장의 주인인지도 확인하지 않음 🔴 Critical ✅

High

# Line Issue Severity 재현
7 48–55 place_order: 재고를 조회한 뒤 따로 차감하는 구조(TOCTOU)라서 경쟁 상태가 생김. 동시에 들어온 주문 두 개가 둘 다 stock=1을 읽고 통과하면 초과 판매(재고가 음수)가 됨. SELECT 시점에는 트랜잭션이 아직 시작되지 않은 상태임 🟠 High 코드 분석
8 51 place_order: 상품이 없으면 stock이 None이라서 TypeError: 'NoneType' object is not subscriptable가 나고 500 에러가 됨 🟠 High ✅
9 25 list_orders: off-by-one. offset = page * PAGE_SIZE 때문에 page=1이 OFFSET 20부터 시작함. 주문이 20개 이하인 사용자는 1페이지가 빈 목록으로 나옴. ORDER BY도 없어서 페이지 순서가 보장되지 않음 🟠 High ✅
10 69–74 apply_coupon: ① except Exception: pass 때문에 오류가 나면 조용히 None을 반환하고, 이후 계산에서 터지거나 0원 처리될 수 있음. ② 할인율 범위 검증이 없어서 percent=150이면 −5,000원, 음수 percent면 오히려 가격이 오름 🟠 High ✅

Suggestions

# Line Suggestion Category
11 11–14 모든 함수가 get_db()로 연결을 열고 닫지 않음. 요청이 몰리면 파일 핸들이 새고 database is locked가 날 수 있음. contextlib.closing이나 요청 단위 연결 관리로 바꿀 것 Performance / Resource
12 31–36 list_orders: 주문마다 order_items를 따로 조회하는 N+1 쿼리. WHERE order_id IN (...) 한 번이나 JOIN으로 묶을 것. created_at은 조회하고도 결과에 넣지 않음 Performance
13 83–84 is_admin: == 비교는 타이밍 공격에 취약함. hmac.compare_digest를 쓸 것 (2번에서 키를 환경변수로 옮기면서 함께 처리) Security
14 63, 72 금액을 float로 계산함(total * rate). 원 단위 정수나 Decimal로 통일하고 반올림 규칙을 명시할 것 Correctness
15 5–6 DB_PATH, INVOICE_DIR이 상대경로라서 실행 위치(cwd)에 따라 다른 DB나 폴더를 보게 됨. 설정이나 환경변수에서 절대경로로 받을 것 Maintainability
16 — 테스트가 하나도 없음. 돈이 오가는 모듈이므로 최소한 아래 "권장 테스트"는 필요함 Test Coverage
17 53–65 예외가 나도 명시적인 rollback이 없음. 지금은 연결이 GC될 때 우연히 롤백되는 것에 기대고 있음. with db:로 트랜잭션 경계를 명시할 것 Correctness

수정 예시

1. SQL 인젝션 (L19)

def search_products(keyword):
    with closing(get_db()) as 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()]

2. 비밀키 (L7, L83)

import hmac

ADMIN_TOKEN = os.environ["SHOP_ADMIN_TOKEN"]  # 기존 sk_live 키는 즉시 폐기·재발급

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

3·4·7·8·17. 주문 처리 (L46–66): 입력 검증과 원자적 차감

def place_order(user_id, product_id, qty):
    if not isinstance(qty, int) or qty <= 0:
        raise ValueError("수량은 1 이상의 정수여야 합니다")

    with closing(get_db()) as db:
        db.isolation_level = None          # 트랜잭션을 직접 제어
        db.execute("BEGIN IMMEDIATE")      # 쓰기 잠금을 먼저 잡아 동시 주문 직렬화
        try:
            product = db.execute(
                "SELECT price FROM products WHERE id = ?", (product_id,)
            ).fetchone()
            if product is None:
                raise ValueError("존재하지 않는 상품")
            amount = product["price"] * qty

            # 조건부 UPDATE: 재고가 충분할 때만 차감
            if db.execute(
                "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
                (qty, product_id, qty),
            ).rowcount != 1:
                raise ValueError("재고 부족")

            if db.execute(
                "UPDATE users SET balance = balance - ? WHERE id = ? AND balance >= ?",
                (amount, user_id, amount),
            ).rowcount != 1:
                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"]),
            )
            db.execute("COMMIT")
            return order_id
        except Exception:
            db.execute("ROLLBACK")
            raise

DB 레벨에서도 막아두면 더 안전합니다: CHECK (stock >= 0), CHECK (balance >= 0), CHECK (qty > 0).

5. IDOR (L40–43)

def get_order(order_id, current_user_id):
    with closing(get_db()) as db:
        row = db.execute(
            "SELECT id, user_id, created_at FROM orders WHERE id = ? AND user_id = ?",
            (order_id, current_user_id),
        ).fetchone()
        return dict(row) if row else None

6. 경로 탐색 (L77–80)

INVOICE_DIR = os.path.realpath(os.environ.get("SHOP_INVOICE_DIR", "invoices"))

def download_invoice(filename, current_user_id):
    path = os.path.realpath(os.path.join(INVOICE_DIR, filename))
    if os.path.commonpath([path, INVOICE_DIR]) != INVOICE_DIR:
        raise PermissionError("잘못된 경로")
    # + 해당 송장이 current_user_id의 주문인지 DB에서 확인
    #   (가장 좋은 방법은 파일명 대신 order_id를 받아 서버가 경로를 만드는 것)
    with open(path, "rb") as f:
        return f.read()

9. 페이지네이션 (L25)

page = max(1, int(page))
offset = (page - 1) * PAGE_SIZE
# SQL에 ORDER BY created_at DESC, id DESC 추가

10. 쿠폰 (L69–74)

def apply_coupon(total, coupon):
    percent = coupon.get("percent")
    if not isinstance(percent, (int, float)) or not 0 <= percent <= 100:
        raise ValueError("유효하지 않은 쿠폰")
    return total - (total * percent) // 100   # 정수 원 단위

쿠폰 dict를 클라이언트가 보내는 구조라면, 할인율은 반드시 서버 DB에서 쿠폰 코드로 조회해야 합니다.


권장 테스트 (머지 조건)

What Looks Good


부록: 재현 결과

임시 디렉터리에 shop.py 사본과 테스트 DB를 만들어 실행한 결과입니다 (원본 파일과 DB는 건드리지 않았습니다).

1 SQLi: [{'id': 1, 'name': 'a@a.com', 'price': 'HASH_A'}, {'id': 1, 'name': 'pen', 'price': 1000},
         {'id': 2, 'name': 'b@b.com', 'price': 'HASH_B'}]
2 negative qty -> stock/balance: (15,) (10000,)        # 재고 5→15, 잔액 0→10000
3 no balance check -> user2 balance: (-3000,)
4 page=1 for user with 5 orders: []
5 IDOR (user 2 reads order 1): {'id': 1, 'user_id': 1, 'created_at': '...'}
6 missing product: TypeError 'NoneType' object is not subscriptable
7 coupon 150%: -5000.0 / missing key: None
8 traversal ../shop.py: b'"""\xec\x9e\x91...'           # shop.py 소스 읽힘
8 absolute /etc/hosts: b'##\n# Host Database\n#'