깔아 두기만 2회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록
shop.py 코드 리뷰
- 대상:
shop.py(84줄, sqlite3 기반 주문 처리 모듈) - 검토일: 2026-10-16
- 검증 방법: 코드 정독 + 임시 SQLite DB를 만들어 아래 문제를 실제로 재현 (원본 파일은 수정하지 않음)
결론: ❌ 머지 불가 (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"
sk_live_접두어로 보아 실제 운영 키입니다. 저장소에 올라가는 순간 코드에 접근하는 모든 사람(또는 저장소 유출 시 외부)이 관리자 권한을 얻습니다.- git 히스토리에 한 번 남으면 파일에서 지워도 남아 있으므로, 이 토큰은 이미 노출된 것으로 보고 즉시 폐기·재발급해야 합니다.
==비교는 타이밍 공격에 취약합니다.
수정:
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)
filename="../shop.py"→ 소스코드(1번의 관리자 토큰 포함)가 그대로 내려갑니다. ✅ 재현filename="/etc/hosts"→os.path.join은 절대경로가 오면 앞부분을 버리므로 서버의 어떤 파일이든 읽힙니다. ✅ 재현- 또 경로가 맞더라도 다른 고객의 영수증을 파일명만 바꿔서 받을 수 있습니다(소유자 확인 없음).
수정: 파일명을 사용자 입력으로 받지 말고 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) 한 번으로:
- 재고: 4 → 104 (재고가 늘어남)
- 잔고: 0 → 100,000원 (
balance - price * -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
except Exception: pass때문에 쿠폰 형식이 잘못되면None을 반환합니다. 호출부가 이를 결제 금액으로 쓰면 0원 처리 또는 엉뚱한 곳에서TypeError가 납니다. (재현:apply_coupon(1000, {})→None)percent범위 검사가 없어 150%면 -500원(재현), 음수면 가격이 올라갑니다.- 쿠폰 유효성(만료, 사용 여부, 소유자) 검증이 없고,
coupondict를 클라이언트가 보낸다면 할인율을 마음대로 정할 수 있습니다. 쿠폰은 서버 DB에서 코드로 조회해야 합니다. - 금액을 float로 계산해 반올림 오차가 생깁니다. 원 단위 정수 또는
Decimal로 계산하세요.
수정 예:
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 및 모든 함수
- 모든 함수가
get_db()로 새 연결을 열고 닫지 않습니다. 트래픽이 늘면 파일 핸들이 쌓이고 SQLite 잠금 문제가 생깁니다. place_order중간에 예외가 나면 rollback 없이 연결이 방치되어, 다른 요청이database is locked를 맞을 수 있습니다.with contextlib.closing(get_db()) as db, db:패턴(또는 요청 단위 연결 관리)을 쓰세요.- 참고: 실제 서비스 규모로 동시 쓰기가 많다면 SQLite 자체가 병목이 됩니다. 최소한
PRAGMA journal_mode=WAL과busy_timeout설정을, 장기적으로는 PostgreSQL 등을 고려하세요.
12. N+1 쿼리 — shop.py:31-36
주문 20건을 보여주려고 쿼리를 21번 실행합니다. JOIN 한 번이나 WHERE order_id IN (...) 한 번으로 묶으세요.
🟢 Low / 개선 제안
list_orders에ORDER BY가 없어 페이지마다 순서가 보장되지 않습니다 →ORDER BY created_at DESC, id DESC.list_orders결과에 조회한created_at을 빼먹고 있습니다(SELECT는 했는데 반환에서 누락).get_order의SELECT *는 컬럼 추가 시 민감 정보가 같이 나갈 수 있으니 필요한 컬럼만 명시하세요.DB_PATH,INVOICE_DIR가 상대경로라 실행 위치에 따라 다른 DB/폴더를 씁니다 → 환경변수나 절대경로로.- 주문 시 결제·재고 변경에 대한 로그/감사 기록이 없습니다.
- 테스트가 전혀 없습니다. 최소한 위 1~10번 시나리오를 그대로 테스트 케이스로 만들어 두면 재발을 막을 수 있습니다.
머지 전 체크리스트
- [ ]
sk_live_...토큰 즉시 폐기·재발급, 환경변수/시크릿 매니저로 이동 (1) - [ ]
search_products파라미터 바인딩 (2) - [ ]
download_invoice경로 검증 + 소유자 확인 (3) - [ ]
get_order소유자 조건 추가 (4) - [ ]
place_order수량 검증·잔고 검사·원자적 차감·트랜잭션 (5, 6, 7, 10) - [ ] 페이지네이션 offset 수정 (8)
- [ ] 쿠폰 서버 검증, 예외 삼키기 제거, 정수 금액 계산 (9)
- [ ] DB 연결 close / rollback 처리 (11)
- [ ] 위 시나리오에 대한 테스트 추가 후 재리뷰
위 Critical·High 항목이 반영되면 다시 한 번 리뷰하는 것을 권장합니다. 이번 주 배포 일정이 빠듯하다면, 최소한 1~7번은 배포 차단 조건(blocker)으로 두세요.