AI 스킬 위키 실습 기록 · Claude Code · Opus 5.5 · 생각 깊이 중간

pr-review-toolkit 플러그인, 같은 PR 검토 6번 결과

가상 쇼핑몰 '달빛상점' 주문 코드에 문제 6개를 심은 변경을 두고, 같은 검토를 플러그인 없이, 깔아 두기만 하고, 플러그인 명령으로 보낸 기록입니다. Claude Code · Opus 5.5 · 생각 깊이 중간.

조건채점부른 에이전트사용량(API 단가)걸린 시간
플러그인 없이 1회차7/7없음0.30달러1분이 안 걸림
플러그인 없이 2회차7/7없음0.30달러1분이 안 걸림
깔아 두기만 1회차7/7없음0.32달러1분이 안 걸림
깔아 두기만 2회차7/7없음0.32달러1분이 안 걸림
/pr-review-toolkit:review-pr 1회차7/7code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer, type-design-analyzer2.03달러2분쯤
/pr-review-toolkit:review-pr 2회차7/7code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer, type-design-analyzer1.63달러2분쯤
검토 대상 변경(git diff HEAD)
diff --git a/shop/coupon.py b/shop/coupon.py
new file mode 100644
index 0000000..f61c3e9
--- /dev/null
+++ b/shop/coupon.py
@@ -0,0 +1,9 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class Coupon:
+    """할인 쿠폰. rate(비율)나 amount(정액) 중 하나만 쓴다."""
+    code: str
+    rate: float = 0.0
+    amount: int = 0
diff --git a/shop/order.py b/shop/order.py
index 722820a..638d8b4 100644
--- a/shop/order.py
+++ b/shop/order.py
@@ -1,5 +1,9 @@
+import logging
+
 from .errors import OrderError
 
+log = logging.getLogger(__name__)
+
 
 def subtotal(items):
     """상품 금액 합계(원)."""
@@ -11,3 +15,42 @@ def shipping_fee(server, region):
         return server.fee(region)
     except ConnectionError as e:
         raise OrderError("배송비를 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요.") from e
+    except KeyError:
+        # 배송 지역표에 없는 곳은 일단 무료 배송으로 처리
+        return 0
+
+
+def apply_coupon(total, coupon):
+    """쿠폰을 적용한 금액. 할인 후 금액은 0원 밑으로 내려가지 않는다."""
+    if coupon is None:
+        return total
+    if coupon.rate:
+        return total * (1 - coupon.rate)
+    return total - coupon.amount
+
+
+def point_balance(server, member_id):
+    """회원 포인트 잔액. 서버 오류는 OrderError로 올린다."""
+    try:
+        return server.balance(member_id)
+    except Exception:
+        return 0
+
+
+def earn_points(paid):
+    """적립 포인트: 결제 금액의 1%, 1회 최대 5,000포인트."""
+    return int(paid * 0.01)
+
+
+def load_grade(member):
+    """회원 등급. 등급 정보가 없으면 기본 등급."""
+    try:
+        return member["grade"]
+    except KeyError:
+        log.warning("member %s has no grade, using BASIC", member.get("id"))
+        return "BASIC"
+
+
+def checkout(items, region, coupon, ship_server):
+    total = apply_coupon(subtotal(items), coupon)
+    return total + shipping_fee(ship_server, region)
diff --git a/tests/test_order.py b/tests/test_order.py
index c441534..6b869f7 100644
--- a/tests/test_order.py
+++ b/tests/test_order.py
@@ -1,5 +1,6 @@
 import unittest
-from shop.order import subtotal
+from shop.coupon import Coupon
+from shop.order import subtotal, apply_coupon, earn_points
 
 
 class SubtotalTest(unittest.TestCase):
@@ -7,5 +8,13 @@ class SubtotalTest(unittest.TestCase):
         self.assertEqual(subtotal([{"price": 4500, "qty": 2}, {"price": 3000, "qty": 1}]), 12000)
 
 
+class CouponTest(unittest.TestCase):
+    def test_amount_coupon(self):
+        self.assertEqual(apply_coupon(12000, Coupon("WELCOME", amount=2000)), 10000)
+
+    def test_earn_points(self):
+        self.assertEqual(earn_points(10000), 100)
+
+
 if __name__ == "__main__":
     unittest.main()

플러그인 없이 1회차

요청: PR 올리기 전에 지금 바꾼 거 검토해 줘.

클로드의 리뷰 전문
PR 올리기 전에 고쳐야 할 버그가 4개 있습니다. 테스트 3개는 통과하지만 정상 경로 2개만 확인하고 있어서, 아래 문제는 하나도 잡지 못합니다. 엣지 케이스 확인 스크립트는 실행 승인이 나지 않아서, 아래 계산 결과는 코드를 읽고 직접 계산한 값입니다.

## 반드시 수정

**1. `apply_coupon` 비율 할인이 float로 계산됩니다** (`shop/order.py:30`)
- `total * (1 - coupon.rate)`는 float를 돌려줍니다. 예를 들어 12900원에 10% 쿠폰을 쓰면 `11610.000000000002` 같은 값이 나오고, 이 값이 `checkout` 합계까지 그대로 넘어갑니다. "float 계산 금지" 규칙에 정면으로 걸립니다.
- `Coupon.rate: float`도 같은 문제입니다(`shop/coupon.py:8`). 정수 퍼센트(`rate_pct: int`)로 받고 `total * rate_pct // 100`로 계산하는 것을 권합니다. 끝전을 내림할지 반올림할지는 정해 두셔야 합니다.

**2. 할인 후 금액이 0원 아래로 내려갑니다** (`shop/order.py:31`)
- docstring에는 "0원 밑으로 내려가지 않는다"고 되어 있는데, 실제로는 `total - coupon.amount`만 합니다. 3000원 주문에 5000원 쿠폰을 쓰면 -2000원이 되고, 여기에 배송비가 더해져 결제 금액이 깎입니다.
- 해결: `max(0, ...)`

**3. `point_balance`가 서버 장애를 숨깁니다** (`shop/order.py:34-39`)
- docstring에는 "서버 오류는 OrderError로 올린다"고 되어 있지만, 실제로는 `except Exception: return 0`입니다. 포인트 서버가 타임아웃 나면 손님 잔액이 조용히 0으로 보입니다. 프로젝트 규칙 위반입니다.
- `shipping_fee`처럼 `ConnectionError`는 `OrderError`로 감싸서 올리고, 없는 회원(`KeyError`)을 어떻게 처리할지는 따로 정해야 합니다.

**4. `earn_points`에 5,000포인트 상한이 없습니다** (`shop/order.py:44`)
- docstring에는 "1회 최대 5,000포인트"라고 되어 있지만 상한 처리가 없습니다. 100만 원을 결제하면 10,000포인트가 쌓입니다.
- `paid * 0.01`도 float 계산입니다. `min(paid // 100, 5000)`로 바꾸면 두 문제가 함께 해결됩니다.

## 확인 필요

**5. 없는 배송 지역을 무료 배송으로 처리합니다** (`shop/order.py:18-20`)
- 오타가 났거나 지원하지 않는 지역이면 배송비가 0원이 되고, 로그도 남지 않습니다. 정말 의도한 정책이 아니라면 `OrderError`("배송할 수 없는 지역")를 올리는 쪽이 안전합니다. 무료 배송이 의도라면 최소한 경고 로그는 남겨 주세요.

**6. 쿠폰에 `rate`와 `amount`가 둘 다 들어오면 걸러지지 않습니다**
- 이 경우 `rate`만 적용되고 `amount`는 조용히 무시됩니다. `Coupon.__post_init__`에서 둘 중 하나만 들어왔는지 검사하는 것을 권합니다.

## 테스트 보강
아래 케이스를 추가하면 위 버그 1~4를 모두 잡을 수 있습니다.
- 비율 쿠폰: 결과가 `int`인지, 끝전 처리가 맞는지
- 주문 금액보다 큰 정액 쿠폰: 결과가 0원인지
- `earn_points`: 상한 5,000이 걸리는지
- `point_balance`: `PointServer(down=True)`일 때 `OrderError`가 나는지
- `checkout`: 전체 흐름이 맞는지

`load_grade`는 외부 호출이 아니고, 등급이 없을 때 경고 로그를 남기고 기본값을 쓰기 때문에 문제없어 보입니다.

원하시면 1~4번 수정과 테스트 추가까지 바로 진행하겠습니다. 5번은 정책 결정이 필요해서 어떻게 할지 알려 주세요.

플러그인 없이 2회차

요청: PR 올리기 전에 지금 바꾼 거 검토해 줘.

클로드의 리뷰 전문
코드를 읽어 보니 그대로 PR을 올리면 안 되는 문제가 있습니다. `python3 -m unittest`는 3개 모두 통과했습니다. 하지만 테스트가 문제 지점을 하나도 건드리지 않아서 통과한 것입니다. 경계값을 넣어 직접 돌려 보는 명령은 승인을 받지 못해 실행하지 못했고, 아래 결과값은 코드를 읽고 계산한 것입니다.

## 🔴 고쳐야 할 것

**1. `apply_coupon`의 비율 쿠폰이 float를 돌려줌** (`shop/order.py:28`)
`total * (1 - coupon.rate)`의 결과가 float입니다. 예를 들어 12,900원에 10% 쿠폰을 쓰면 `11610.000000000002` 같은 값이 나옵니다. 이 값이 `checkout`을 거쳐 최종 결제 금액까지 float로 남습니다. CLAUDE.md의 "float 계산 금지" 규칙에 어긋나고, 예전에 정산이 어긋났던 것과 같은 경우입니다.
- `Coupon.rate: float`부터 정수로 바꾸는 편이 좋습니다. 예: `rate_pct: int` 또는 퍼밀(‰)
- 계산은 `total - total * rate_pct // 100`처럼 정수 연산으로 하고, 내림·반올림 기준도 정해야 합니다.

**2. 할인 후 금액이 음수가 됨** (`shop/order.py:29`)
docstring에는 "0원 밑으로 내려가지 않는다"고 되어 있지만 하한 처리가 없습니다. 1,000원 주문에 2,000원 쿠폰을 쓰면 `-1000`이 됩니다. `max(0, ...)`가 필요합니다.

**3. `point_balance`가 모든 예외를 삼키고 0을 돌려줌** (`shop/order.py:35-36`)
docstring에는 "서버 오류는 OrderError로 올린다"고 되어 있지만 실제로는 `except Exception: return 0`입니다. 포인트 서버가 죽으면 잔액이 0포인트로 보이게 됩니다. CLAUDE.md의 "외부 호출 실패는 OrderError로" 규칙 위반입니다. `shipping_fee`처럼 `ConnectionError`를 받아 `OrderError`로 올려야 합니다. 회원이 없을 때(`KeyError`)를 어떻게 처리할지도 따로 정해야 합니다.

**4. `earn_points`에 1회 최대 5,000포인트 상한이 없음** (`shop/order.py:41`)
docstring과 달리 100만 원을 결제하면 10,000포인트가 적립됩니다. 계산에 `* 0.01`라는 float도 쓰고 있습니다. `min(paid // 100, 5000)`으로 바꾸면 두 문제가 같이 해결됩니다.

**5. 배송지역표에 없는 지역을 무료 배송으로 처리** (`shop/order.py:18-20`)
오타가 난 지역이나 지원하지 않는 지역이 조용히 0원 배송비로 결제됩니다. 규칙의 취지대로라면 `OrderError`("배송할 수 없는 지역입니다" 등)로 올리는 것이 맞습니다. 정말로 무료 배송이 의도된 정책이라면 PR 설명에 그 근거를 적어 주세요.

## 🟡 확인·개선

- **`Coupon`이 "rate나 amount 중 하나만"이라는 조건을 강제하지 않음**: 두 값이 다 들어오면 `amount`가 조용히 무시됩니다. `__post_init__`에서 검증하는 것을 권합니다.
- **테스트 커버리지**: 정액 쿠폰 정상 케이스와 1% 적립만 있습니다. 비율 쿠폰, 음수 하한, 적립 상한, 포인트·배송 서버 장애, 미지원 지역 테스트를 추가하면 위의 1~5번이 바로 드러납니다.
- **`load_grade`와 `point_balance`는 이번 변경에서 아무 데서도 호출되지 않습니다.** 의도한 것이 아니라면 다음 PR로 미루는 것도 방법입니다.

1~4번은 docstring이나 CLAUDE.md 규칙과 코드가 직접 어긋나는 버그라서 PR 전에 고치는 것을 권합니다. 원하시면 수정과 테스트 추가까지 진행하겠습니다.

깔아 두기만 1회차

요청: PR 올리기 전에 지금 바꾼 거 검토해 줘.

클로드의 리뷰 전문
PR 올리기 전에 고쳐야 할 게 있습니다. 테스트 3개는 통과하지만(`python3 -m unittest` OK), 쿠폰·포인트 코드에 정산 버그와 CLAUDE.md 규칙 위반이 몇 개 있습니다. 아래 동작은 코드를 읽고 판단한 것이고, 직접 실행해 확인하지는 못했습니다(경계값 확인용 스크립트가 권한 승인에서 막혔습니다).

## 🔴 꼭 고쳐야 함

**1. 비율 쿠폰을 float로 계산함** — `shop/order.py:28`
`total * (1 - coupon.rate)`의 결과가 float입니다. 10,000원에 10% 쿠폰이면 `9000.0`, 3,333원에 30% 쿠폰이면 `2333.1`이 됩니다. "금액은 모두 정수, float 금지" 규칙을 정면으로 어기고, 이 값이 `checkout()` 합계로 그대로 넘어갑니다. `Coupon.rate: float`(`shop/coupon.py:8`)도 같은 문제입니다. 할인율을 정수 퍼센트(`rate_pct: int`)로 바꾸고 `total * rate_pct // 100`처럼 정수 연산으로 처리하길 권합니다. 반올림을 내림으로 할지 반올림으로 할지는 정책으로 정해야 합니다.

**2. 할인 후 금액이 0원 밑으로 내려갈 수 있음** — `shop/order.py:29`
docstring에는 "0원 밑으로 내려가지 않는다"고 되어 있지만 실제로는 하한이 없습니다. 1,000원에 2,000원 쿠폰을 쓰면 `-1000`이 되고, 배송비를 더하면 결제액이 깎입니다. `max(0, ...)`가 필요합니다.

**3. 포인트 서버 오류를 숨김** — `shop/order.py:35-36`
`except Exception: return 0`이라서 서버가 다운돼도 잔액이 0으로 보입니다. docstring("OrderError로 올린다")과도 다르고 CLAUDE.md 규칙도 어깁니다. `ConnectionError`는 `OrderError`로 올려야 합니다. 회원 ID가 없을 때 나는 `KeyError`도 0으로 볼지 오류로 볼지 따로 정해야 합니다. 지금은 `Exception` 전체를 잡아서 오타 같은 코드 버그까지 묻힙니다.

**4. 적립 포인트에 최대치가 없고 float로 계산함** — `shop/order.py:41`
docstring에는 "1회 최대 5,000포인트"라고 되어 있지만 상한 처리가 없습니다. 100만 원을 결제하면 10,000포인트가 적립됩니다. `paid * 0.01`도 float 연산입니다. `min(paid // 100, 5000)`으로 바꾸면 됩니다.

**5. 등록되지 않은 배송 지역을 무료 배송으로 처리함** — `shop/order.py:18-20`
지역표에 없는 지역이나 오타("Seoul", "jejudo")가 들어오면 조용히 배송비 0원이 됩니다. 매출이 새고 아무도 알아채지 못합니다. 주석의 "일단"을 보면 임시 처리인 것 같은데, `OrderError("배송할 수 없는 지역입니다")`로 올리는 게 맞아 보입니다. 무료 배송이 의도된 정책이라면 적어도 로그는 남겨야 합니다.

## 🟡 개선 권장

- **`Coupon`이 "rate나 amount 중 하나만"이라는 조건을 검사하지 않음** (`shop/coupon.py`): 둘 다 넣으면 `apply_coupon`이 amount를 조용히 무시합니다. `__post_init__`에서 둘 중 하나만 있는지, 음수는 아닌지, 할인율은 0–100 사이인지 검사하면 좋겠습니다.
- **`load_grade`** (`shop/order.py:44-50`): 기본 등급으로 넘어가는 건 docstring에 적힌 의도이고 경고 로그도 남기니 괜찮습니다. 다만 `member.get("id")`의 결과가 None이면 로그로는 어떤 회원인지 추적할 수 없습니다.

## 🧪 테스트 공백

지금 테스트는 정상 경로 2개뿐이고 위 버그를 하나도 잡지 못합니다. 추가하면 좋을 케이스는 이렇습니다.
- 비율 쿠폰의 결과가 `int`인지 (`assertIsInstance`)
- 정액 쿠폰이 금액보다 클 때 0원이 되는지
- `earn_points`가 5,000포인트에서 멈추는지
- 포인트 서버가 다운됐을 때(`PointServer(down=True)`) `OrderError`가 나는지
- 등록되지 않은 배송 지역 처리
- `checkout()` 전체 흐름

원하시면 위 항목들을 바로 고치고 테스트까지 추가하겠습니다. 다만 비율 쿠폰의 반올림 방식과 등록되지 않은 지역 처리는 정책 결정이 필요하니, 원하는 방향을 알려주세요.

깔아 두기만 2회차

요청: PR 올리기 전에 지금 바꾼 거 검토해 줘.

클로드의 리뷰 전문
PR은 아직 올리지 마세요. 정산 금액이 틀리거나 장애가 조용히 묻히는 버그가 5개 있습니다. 테스트 3개는 통과하지만, 이 버그들을 잡는 테스트가 하나도 없습니다.

## 고쳐야 할 것

**1. 비율 쿠폰을 쓰면 금액이 float이 됨** (`shop/order.py:28`)
`total * (1 - coupon.rate)`의 결과는 float라서, 이 값이 `checkout`을 거쳐 최종 결제 금액까지 갑니다. "금액은 정수(원), float 계산 금지"라는 CLAUDE.md 규칙에 정면으로 어긋납니다. `Coupon.rate: float` 필드부터 정수 퍼센트(`rate_pct: int`)로 바꾸고, 계산은 `total * rate_pct // 100`처럼 정수로 하세요. 원 단위를 버릴지 올릴지 같은 반올림 정책도 이참에 정하는 게 좋습니다.

**2. 할인 후 금액이 음수가 될 수 있음** (`shop/order.py:29`)
docstring에는 "0원 밑으로 내려가지 않는다"고 되어 있지만 실제로는 하한 처리가 없습니다. 1,000원 주문에 3,000원 쿠폰을 쓰면 -2,000원이 되고, 배송비를 더해도 결제 금액이 깎입니다. 1번에서 말한 비율 할인 쪽도 같이 `max(0, ...)`로 막아야 합니다.

**3. 포인트 서버 장애를 0으로 삼킴** (`shop/order.py:35-36`)
`except Exception: return 0` 때문에 서버가 죽으면 잔액이 그냥 0원으로 보입니다. docstring("OrderError로 올린다")과도, CLAUDE.md의 외부 호출 규칙과도 반대입니다. 범위도 너무 넓어서 오타 같은 코드 버그까지 다 삼킵니다. `ConnectionError`만 `OrderError`로 바꿔서 올리고, 회원이 없을 때 나는 `KeyError`를 어떻게 처리할지는 따로 정하세요.

**4. 지역표에 없는 지역은 무료 배송** (`shop/order.py:18-20`)
`"busan"`이나 오타 난 지역명이 전부 배송비 0원으로 결제됩니다. 돈이 새는 데다 로그 한 줄도 남지 않습니다. 정말 무료 배송이 정책이라면 지역표에 명시적으로 넣고, 그게 아니라면 `OrderError`("배송할 수 없는 지역")로 올리는 게 맞습니다.

**5. 적립 포인트 최대 5,000 제한이 없고, float으로 계산함** (`shop/order.py:41`)
docstring에는 "1회 최대 5,000포인트"라고 되어 있는데 실제 제한이 없습니다. 100만 원 결제면 10,000포인트가 쌓입니다. `min(paid // 100, 5000)`으로 바꾸면 float 계산 문제도 같이 해결됩니다.

## 함께 보면 좋은 것

- **`Coupon`의 "rate나 amount 중 하나만" 조건이 강제되지 않음** (`shop/coupon.py:6`): 둘 다 넣으면 rate만 적용되고 amount는 아무 말 없이 무시됩니다. `__post_init__`에서 검사하고, 음수나 100%를 넘는 값도 같이 막으세요.
- **`checkout`이 포인트 쪽을 쓰지 않음**: `point_balance`와 `earn_points`는 아무 데서도 호출되지 않습니다. 이번 PR에서 연결할 계획이었는지 확인해 보세요.
- **`load_grade`**: 기본 등급으로 넘어갈 때 경고 로그를 남기므로 지금 형태로 괜찮습니다.

## 빠진 테스트

지금 있는 쿠폰·포인트 테스트는 정상 경로 두 개뿐입니다. 위 버그를 잡으려면 아래 테스트가 필요합니다.
- 비율 쿠폰 적용 후 결과가 `int`인지 (예: `assertIsInstance`)
- 할인액이 주문 금액보다 클 때 0원이 되는지
- `earn_points(1_000_000) == 5000`
- `PointServer(down=True)`, `ShippingServer(down=True)`, 없는 지역에서 각각 `OrderError`가 나는지

`python3 -m unittest`는 실행했고 3개 모두 통과했습니다. 위 버그들을 직접 재현하는 스크립트는 실행 승인이 나지 않아 돌리지 못했고, 코드만 읽고 판단한 결과입니다. 원하시면 바로 고치고 테스트도 추가하겠습니다.

/pr-review-toolkit:review-pr 1회차

요청: /pr-review-toolkit:review-pr

클로드의 리뷰 전문
# PR 리뷰 요약 (`feature/coupon-points`, 미커밋 변경)

5개 에이전트(코드, 조용한 실패, 테스트, 주석, 타입 설계)가 모두 끝났습니다. 아래 버그는 에이전트들이 실제로 실행해서 재현했습니다. 지금 테스트는 3개 모두 통과하지만 정상 경로만 확인하기 때문에, 이 버그들은 하나도 잡지 못합니다.

## Critical: 머지 전에 반드시 고칠 것 (4건)
1. **`point_balance`가 모든 오류를 삼키고 0을 반환합니다** `shop/order.py:36-37`
   - `except Exception`이 서버 다운, 없는 회원, `server=None` 같은 코딩 실수까지 전부 0으로 바꿉니다.
   - docstring과 CLAUDE.md는 둘 다 OrderError로 올리라고 합니다.
   - 고치는 방법: `except ConnectionError as e: raise OrderError(...) from e`. 코딩 실수에서 나는 예외는 잡지 않습니다.
2. **`apply_coupon`이 float를 돌려주고, 금액이 0원 밑으로 내려갑니다** `shop/order.py:27-29`
   - 정액 3,000원 쿠폰을 1,000원 주문에 쓰면 `-2000`이 됩니다.
   - 비율 쿠폰은 `11110.5`처럼 float가 나옵니다. 이 값이 그대로 `checkout`의 결제 금액이 됩니다.
   - 금액을 `int()`로 잘라내는 방식은 1~99% 할인율과 다양한 금액 조합 중 3,658개에서 1원 오차가 납니다. 예를 들어 4,300원에 6% 할인이면 4,042원이어야 하는데 4,041원이 됩니다.
   - 고치는 방법: `total * pct // 100` 같은 정수 연산으로 바꾸고, 결과에 `max(0, …)`를 씌웁니다.
3. **`earn_points`가 float로 계산하고, 5,000포인트 상한이 없습니다** `shop/order.py:42`
   - 100만 원을 결제하면 10,000포인트가 적립됩니다.
   - 고치는 방법: `min(paid // 100, 5000)`.
4. **`shipping_fee`가 모르는 지역을 조용히 무료 배송으로 처리합니다** `shop/order.py:18-20`
   - 지역 이름에 오타가 있거나 대소문자만 달라도 배송비 6,000원이 사라지고, 로그도 남지 않습니다.
   - 고치는 방법: OrderError로 올립니다. 무료 배송 지역이 정말 필요하다면 지역표에 0원으로 명시합니다.

## Important: 고치는 게 좋은 것 (3건)
- **`Coupon` 타입이 제약을 하나도 강제하지 않습니다** `shop/coupon.py`
  - rate와 amount를 둘 다 넣으면 amount가 말없이 무시됩니다.
  - 음수 amount를 넣으면 금액이 오히려 늘어나고, 100%를 넘는 rate도 들어갑니다.
  - 고치는 방법: `frozen=True`로 바꾸고, `percent: int | None`과 `amount: int | None`에 `__post_init__` 검증을 넣고, `discount()` 메서드를 추가합니다.
- **테스트가 정상 경로 2개뿐입니다**
  - 위 Critical 4건을 확인하는 테스트와 `checkout` 통합 테스트가 필요합니다.
  - `assertEqual(10800.0, 10800)`은 통과해 버리므로, 금액 테스트에는 `assertIsInstance(r, int)`도 함께 넣어야 합니다.
- **`shipping_fee`의 `except ConnectionError`가 너무 좁습니다** `shop/order.py:16`
  - 이번 diff 전부터 있던 코드입니다.
  - 실제 HTTP 클라이언트로 바뀌면 `TimeoutError`와 `URLError`가 OrderError로 바뀌지 않고 그대로 올라갑니다.

## Suggestions
- `load_grade`의 BASIC 기본값은 외부 호출이 아니고 경고 로그도 남겨서 문제없습니다. 다만 `grade`가 `None`이나 빈 문자열이면 기본값이 적용되지 않습니다.
- `checkout`에 docstring을 추가하면 좋습니다. 쿠폰은 상품 합계에만 적용되고, 배송비 서버가 실패하면 OrderError가 난다는 점을 적어 두세요.
- `test_earn_points`가 `CouponTest` 안에 있습니다. `EarnPointsTest`로 분리하세요.
- 주석 "일단 무료 배송"의 "일단"은 임시 처리라는 뜻이라, 나중에 이유를 아무도 모르는 주석이 됩니다.

## Strengths
- docstring에 사양(0원 하한, 5,000포인트 상한, OrderError)이 구체적으로 적혀 있어서, 구현이 빠진 부분을 바로 찾을 수 있었습니다. docstring은 고칠 필요 없고 코드를 맞추면 됩니다.
- 쿠폰을 별도 모듈로 분리했고, 표준 라이브러리만 씁니다.
- `shop/clients.py`에 `down=True` 옵션이 있는 테스트용 서버가 이미 있어서, 장애 경로 테스트를 mock 없이 바로 쓸 수 있습니다.

## 고치기 전에 정할 정책 3가지
1. 지역표에 없는 지역: 주문을 막을지(OrderError), 무료 배송으로 할지
2. 포인트 서버에 기록이 없는 회원: 잔액 0으로 볼지, OrderError로 올릴지
3. 비율 할인에서 1원 미만 처리: 버림, 반올림, 올림 중 무엇으로 할지

## 다음 순서
1. 위 정책 3가지를 정합니다.
2. Critical 4건을 고치고 테스트를 추가합니다.
3. `Coupon`에 검증을 넣습니다.
4. 리뷰를 다시 돌린 뒤 code-simplifier로 다듬습니다.

이번에는 리뷰만 요청하셔서 파일은 수정하지 않았습니다. 정책을 정해 주시면 수정하고 테스트까지 추가하겠습니다. 정하지 않으시면 둘 다 OrderError로 올리고 할인은 버림으로 처리하는 것을 기본안으로 제안합니다.

/pr-review-toolkit:review-pr 2회차

요청: /pr-review-toolkit:review-pr

클로드의 리뷰 전문
# PR 리뷰 요약: `feature/coupon-points`

전문 리뷰 에이전트 5개를 돌렸습니다. `code-simplifier`는 리뷰를 통과한 코드를 다듬는 단계라서 이번에는 실행하지 않았습니다. `python3 -m unittest`는 테스트 3개가 모두 통과하지만, 아래 문제는 어느 테스트도 확인하지 않습니다. 원격 PR이 있는지는 `gh`가 로그인되어 있지 않아 확인하지 못했습니다.

결론: **지금 상태로는 머지하면 안 됩니다.** docstring에 적힌 규칙 세 가지(0원 하한, OrderError로 올리기, 5,000P 상한)가 코드에는 없고, CLAUDE.md 규칙도 두 군데에서 어깁니다.

## Critical: 머지 전에 반드시 고칠 것 (4건)
- **[code-reviewer, type-design] 정률 쿠폰이 float 금액을 만듭니다** `shop/order.py:28`, `shop/coupon.py:8`
  - `total * (1 - coupon.rate)` 때문에 12345원에 10%를 적용하면 `11110.5`가 나옵니다.
  - 이 float 값이 `checkout`을 거쳐 결제 금액까지 그대로 갑니다. CLAUDE.md의 float 금지 규칙 위반입니다.
  - 고치는 방법: 비율을 정수 퍼센트로 받고 `total * percent // 100`으로 계산합니다.
- **[silent-failure-hunter, code-reviewer] `point_balance`가 오류를 삼킵니다** `shop/order.py:36-37`
  - `except Exception: return 0` 때문에 포인트 서버가 타임아웃되면 손님에게 "잔액 0원"으로 보이고 로그도 남지 않습니다.
  - docstring은 "OrderError로 올린다"고 하는데 실제 동작은 반대입니다.
  - 고치는 방법: `shipping_fee`처럼 `except ConnectionError as e: raise OrderError(...) from e`로 바꿉니다.
- **[code-reviewer, comment-analyzer] 쿠폰 적용 후 금액이 음수가 될 수 있습니다** `shop/order.py:29`
  - `apply_coupon(1000, Coupon("X", amount=3000))`의 결과가 `-2000`입니다. docstring의 0원 하한이 구현되지 않았습니다.
- **[code-reviewer, comment-analyzer] `earn_points`에 5,000P 상한이 없고 float로 계산합니다** `shop/order.py:42`
  - 100만 원을 결제하면 10,000P가 적립됩니다.
  - 고치는 방법: `min(paid // 100, 5000)`으로 바꿉니다.

## Important: 고치는 것이 좋은 것 (4건)
- **[silent-failure-hunter] 배송 지역표에 없는 지역이 조용히 무료 배송이 됩니다** `shop/order.py:18-20`
  - 오타나 대소문자가 다른 지역이 들어와도 배송비 0원으로 결제되고 로그도 없습니다. "일단"이라는 주석을 보면 임시 처리로 보입니다.
  - 정책 결정이 필요합니다. 제 권고는 `OrderError`를 올리는 것입니다.
- **[type-design] `Coupon`이 자기 규칙을 강제하지 않습니다** `shop/coupon.py`
  - rate와 amount를 둘 다 넣으면 amount가 조용히 무시됩니다. `rate=1.5`, `amount=-500`도 그대로 만들어집니다.
  - `rate`가 0.1을 뜻하는지 10을 뜻하는지 타입만 봐서는 알 수 없습니다.
  - 고치는 방법: `frozen=True`로 만들고 `__post_init__`에서 검증합니다.
- **[pr-test-analyzer] 테스트에 실패 경로가 하나도 없습니다** `tests/test_order.py`
  - 다음 경우에 테스트가 필요합니다.
    - 정률 쿠폰 결과가 `int`인지
    - 쿠폰이 주문 금액보다 클 때 0원이 되는지
    - 5,000P 상한
    - `PointServer(down=True)`와 `ShippingServer(down=True)`일 때 `OrderError`가 나는지
    - 미등록 지역
    - `checkout` 전체 흐름
  - `assertEqual(11110.0, 11110)`은 통과하므로 금액 테스트에는 `assertIsInstance(result, int)`도 함께 넣어야 합니다.
- **[pr-test-analyzer] `test_earn_points`가 `CouponTest` 클래스 안에 들어 있습니다.** 따로 분리하는 것이 좋습니다.

## Suggestions (3건)
- [comment-analyzer] `shipping_fee`에 docstring이 없습니다. 무료 배송 정책을 유지한다면 근거나 이슈 번호를 주석에 적어 두세요. `shop/order.py:13-19`
- [comment-analyzer] 정률 할인과 포인트 계산의 원 단위 미만 처리(버림인지 반올림인지)를 docstring에 적어 두세요.
- [silent-failure-hunter] `load_grade`가 BASIC으로 대체하는 것이 정해진 정책인지 확인하세요. 등급이 할인율에 영향을 준다면 손님에게 불리해질 수 있습니다. `shop/order.py:45-51`

## 잘된 점
- 표준 라이브러리만 쓴다는 규칙을 지켰습니다.
- `amount: int`로 금액을 정수로 다뤘습니다.
- `shipping_fee`의 `ConnectionError`를 `OrderError`로 바꿔 올리는 처리는 올바릅니다.
- `load_grade`는 기본값으로 대체할 때 경고 로그를 남깁니다.
- 기존 테스트는 구현 방식이 아니라 반환값을 확인하므로 리팩터링해도 잘 깨지지 않습니다.

## 권장 순서
1. Critical 4건을 고칩니다. 코드를 docstring에 맞추는 쪽이 맞습니다.
2. 미등록 지역을 무료 배송으로 둘지 정하고, `Coupon`에 검증을 넣습니다.
3. 위 테스트들을 추가합니다.
4. `/pr-review-toolkit:review-pr code errors tests`로 다시 리뷰합니다.

이번에는 리뷰만 하고 파일은 고치지 않았습니다. 미등록 지역 처리만 정해 주시면 수정과 테스트 추가까지 바로 진행하겠습니다.

리뷰 글로 돌아가기