12. 카드 보관함(Card Vault) 기능
🔗함께 보기: 구독 기능
이 문서는 카드 보관함 기능을 호출 진입(라우트)부터 반환까지 코드 흐름으로 따라갑니다. 카드 등록·교체·삭제·활성/비활성 토글, 빌링키 암호화 보관, 비활성 카드 결제 차단, 카드별 결제내역 표시를 다룹니다.
🍎쉽게 말하면 카드 보관함은 "토스가 발급한 빌링키(자동결제 열쇠)를 서버 금고에 암호화해 넣어 두고, 구독·결제가 필요할 때 꺼내 쓰는 곳"입니다.
(서비스, 외부 사용자)당 카드 1장만 보관합니다.
1. 기능 개요·관련 파일·DB 테이블
1-1. 한 줄 정의
토스에서 발급한 빌링키를 AES-GCM으로 암호화해 cards 테이블에 보관하고, 구독·단건 결제가 이 카드를 참조해 자동결제합니다. (service_id, external_user_id) 쌍당 1건만 허용하며, 재등록 시 같은 행을 교체합니다.
1-2. 관련 파일
| 파일 | 역할 |
|---|---|
app/api/v1/cards.py |
외부 API 라우터 — POST/GET/DELETE /api/v1/cards |
app/services/cards.py |
서비스 레이어 — 등록/교체·조회·삭제·활성 토글 |
app/models/card.py |
Card 모델(cards 테이블) |
app/core/crypto.py |
AesGcmCipher — 빌링키 암호화/복호화 |
app/core/security.py |
sha256_hex — 빌링키 해시 |
app/services/payment_utils.py |
safe_delete_billing_key, CUSTOMER_KEY_RE |
app/toss/client.py |
토스 빌링키 발급·삭제 |
app/notifications/service_notify.py |
카드 이벤트 서비스 알림 상수 |
1-3. DB 테이블 — cards (app/models/card.py:15)
| 컬럼 | 타입 | 설명 |
|---|---|---|
id |
UUID PK | 카드 고유 ID |
service_id |
UUID FK → services (RESTRICT) | 소속 서비스 |
external_user_id |
VARCHAR(255) | 외부 서비스 사용자 ID |
customer_key |
VARCHAR(300) | 토스 customerKey |
billing_key_encrypted |
VARCHAR(1024) | 빌링키 AES-GCM 암호문(평문 저장 안 함) |
billing_key_hash |
VARCHAR(64) | 빌링키 SHA-256 해시(중복탐지·조회용) |
card_info |
JSONB | 마스킹 카드번호·발급사 등 토스 응답 일부 |
is_active |
BOOLEAN (기본 true) | false면 이 카드로의 모든 결제 차단 |
created_at/updated_at |
timestamptz | 등록/교체 시각 |
유니크 제약 uq_cards_service_user가 (service_id, external_user_id) 쌍당 1건을 DB 수준에서 강제합니다(app/models/card.py:25).
💡참고: 빌링키 원문은 어디에도 저장하지 않습니다. 암호문(
billing_key_encrypted)만 보관하고, 자동결제 시점에 복호화해서 토스에 전달합니다.
2. 주요 흐름별 단계 추적
2-1. 카드 등록/교체 — POST /api/v1/cards
요청 → 라우터 → 서비스 함수 → 토스 → DB → 감사로그 → 알림 → 반환의 전체 경로입니다.
1) 라우터 진입 (app/api/v1/cards.py:52 register_card)
빌링키 발급(토스 호출)을 수반하므로 일반 인증이 아닌 결제 전용 처리율 제한 payment_rate_limit을 통과합니다.
@router.post("/cards", status_code=201, response_model=CardResponse, ...)
async def register_card(
payload: CardRegisterRequest,
service: Service = Depends(payment_rate_limit), # 결제 전용 처리율 제한 + 인증
db: AsyncSession = Depends(get_db),
toss: TossClient = Depends(get_toss),
cipher: AesGcmCipher = Depends(get_cipher),
notifier=Depends(get_notifier),
):
card = await card_service.register_or_replace_card(
db, toss, cipher,
service=service,
external_user_id=payload.external_user_id,
customer_key=payload.customer_key,
auth_key=payload.auth_key,
notifier=notifier,
)
return CardResponse.from_model(card) # 마스킹 정보만 — billingKey 비포함
2) 서비스 함수 (app/services/cards.py:163 register_or_replace_card)
시그니처와 핵심 흐름:
async def register_or_replace_card(
db, toss, cipher, *,
service, external_user_id, customer_key, auth_key, notifier=None,
) -> Card:
단계 추적:
| # | 단계 | 코드 위치 | 외부호출/DB |
|---|---|---|---|
| 1 | customer_key 형식 검증(CUSTOMER_KEY_RE) |
cards.py:195 |
— |
| 2 | external_user_id 빈값/255자 초과 검증 |
cards.py:198 |
— |
| 3 | 토스 빌링키 발급 | cards.py:202 |
toss.issue_billing_key(auth_key, customer_key) → BillingKeyResult |
| 4 | 기존 카드 조회로 교체/신규 분기 | cards.py:205 |
SELECT cards |
| 5a | 교체: 기존 행 갱신 | cards.py:208-215 |
UPDATE(메모리) |
| 5b | 신규: Card 삽입 + flush |
cards.py:218-239 |
INSERT, 충돌 시 IntegrityError |
| 6 | 감사 로그 + commit | cards.py:244-254 |
record_audit + COMMIT |
| 7 | 교체 시 옛 빌링키 best-effort 삭제 | cards.py:257-264 |
safe_delete_billing_key |
| 8 | 서비스 알림(best-effort) | cards.py:267 |
notifier.send |
| 9 | Card 반환 |
cards.py:271 |
— |
빌링키 발급 후 암호화 저장 부분(신규):
bk = await toss.issue_billing_key(auth_key, customer_key) # 토스 발급
...
card = Card(
service_id=service.id,
external_user_id=external_user_id,
customer_key=customer_key,
billing_key_encrypted=cipher.encrypt(bk.billing_key), # AES-GCM 암호화 저장
billing_key_hash=sha256_hex(bk.billing_key), # 중복탐지용 해시
card_info=bk.card, # 마스킹 번호·발급사
)
db.add(card)
⚠️중요: 신규 등록은
db.add(card)직후db.flush()를 실행합니다(cards.py:234).SELECT후INSERT사이에 동시 요청이 같은 키로 들어오면uq_cards_service_user유니크 제약이 위반되어IntegrityError가 납니다. 이때rollback→ 패자 요청이 발급한 고아 빌링키를 best-effort 삭제 →ConflictError를 던집니다(cards.py:235-239).
3) 반환 — CardResponse.from_model(card)는 card_info(마스킹 정보)만 담고 billing_key_encrypted는 절대 응답에 넣지 않습니다.
2-2. 카드 조회 — GET /api/v1/cards/{external_user_id}
app/api/v1/cards.py:90 get_card. 읽기 전용이므로 일반 HMAC 인증 authenticate_service를 사용합니다.
card = await card_service.get_card(
db, service_id=service.id, external_user_id=external_user_id)
if card is None:
raise NotFoundError("등록된 카드가 없습니다") # 404
return CardResponse.from_model(card)
서비스 함수 get_card(app/services/cards.py:95)는 (service_id, external_user_id)로 단일 카드를 조회하고, 없으면 예외 없이 None을 반환합니다(라우터에서 404로 변환).
2-3. 카드 삭제 — DELETE /api/v1/cards/{external_user_id}
1) 라우터 (app/api/v1/cards.py:122 delete_card) — 실제 과금은 없으므로 일반 인증 authenticate_service. 응답은 204 No Content.
2) 서비스 함수 (app/services/cards.py:274 delete_card) 단계 추적:
| # | 단계 | 코드 위치 | DB/외부 |
|---|---|---|---|
| 1 | 카드 조회 — 없으면 NotFoundError |
cards.py:308 |
SELECT cards |
| 2 | billing-active 구독이 카드 참조 시 ConflictError |
cards.py:314-321 |
SELECT subscriptions |
| 3 | CANCELED/EXPIRED 구독의 card_id → NULL |
cards.py:328-334 |
UPDATE |
| 4 | 빌링키 평문 확보(삭제 전) | cards.py:337 |
cipher.decrypt |
| 5 | 카드 삭제 + 감사 로그 + commit | cards.py:340-350 |
DELETE + record_audit + COMMIT |
| 6 | 서비스 알림(best-effort) | cards.py:353 |
notifier.send |
| 7 | 커밋 후 토스 빌링키 best-effort 삭제 | cards.py:356 |
safe_delete_billing_key |
활성 구독 차단 검사 핵심:
blocking_sub = await db.scalar(
select(Subscription).where(
Subscription.card_id == card.id,
Subscription.status.in_(CARD_DELETE_BLOCKING_STATUSES),
)
)
if blocking_sub is not None:
raise ConflictError("활성 구독이 사용 중인 카드는 삭제할 수 없습니다")
⚠️주의: 빌링키 복호화는 반드시
db.delete(card)전에 합니다(cards.py:337). 삭제 후에는 암호문에 접근할 수 없어 토스 측 빌링키를 지울 수 없게 됩니다.
2-4. 카드 활성/비활성 토글 — set_card_active (어드민)
app/services/cards.py:113 set_card_active. 어드민 라우트 POST /admin/cards/{id}/toggle에서 호출됩니다(actor_type=USER).
async def set_card_active(db, *, card_id, is_active, actor_user_id=None, notifier=None) -> Card:
card = await db.get(Card, card_id)
if card is None:
raise NotFoundError("카드를 찾을 수 없습니다")
if card.is_active == is_active:
return card # 멱등 — 같은 상태면 감사로그도 안 남김
card.is_active = is_active
await record_audit(db, actor_type="USER", actor_user_id=actor_user_id,
action="card.activate" if is_active else "card.deactivate",
target_type="card", target_id=str(card.id),
detail=_card_audit_detail(card, is_active=is_active))
await db.commit()
await _notify_card(db, notifier, card,
event=(EVENT_CARD_ACTIVATED if is_active else EVENT_CARD_DEACTIVATED),
desc=("카드 활성화" if is_active else "카드 비활성화"))
return card
💡참고: 이미 원하는 상태면 아무 것도 하지 않습니다(멱등). 중복 감사로그를 막기 위함입니다(
cards.py:143).
2-5. 카드별 결제내역 (관리자 화면)
Payment 테이블에는 card_id 컬럼이 없습니다. 대신 카드의 고유키 (service_id, external_user_id)가 Payment에도 동일하게 존재하므로, 같은 (service_id, external_user_id)의 Payment를 그 카드의 결제내역으로 조회합니다(구독·일반결제 모두 포함). 카드 상세 화면(/admin/cards/{card_id})이 이 방식을 씁니다(스키마 변경 없음).
3. 상태·제약·에러 처리
3-1. 비활성 카드 결제 차단
is_active=False이면 모든 결제 경로에서 차단됩니다. 각 경로는 get_card 직후 is_active를 검사합니다.
| 결제 경로 | 위치 | 비활성 시 동작 |
|---|---|---|
| 구독 자동연장·재시도 | app/services/renewals.py:413 _renew_one |
토스 호출 없이 합성 TossError("CARD_INACTIVE") → 기존 실패 처리(PAST_DUE/정지) |
| 구독 생성 | app/services/subscriptions.py:210 |
ConflictError(생성 차단) |
| 수동 재결제 | app/services/subscriptions.py:387 _perform_manual_charge |
PaymentFailedError(code="CARD_INACTIVE") |
| 일반결제(one-off) | app/services/payments.py create_one_off_payment |
ConflictError |
자동연장 경로의 합성 에러(renewals.py:413-426):
if card is None or sub.card_id is None or not card.is_active:
exc = (TossError("CARD_INACTIVE", "비활성화된 카드입니다")
if card is not None and not card.is_active
else TossError("NO_BILLING_KEY", "등록된 카드가 없습니다"))
await db.commit()
await db.refresh(payment, with_for_update=True)
await db.refresh(sub, with_for_update=True)
await _handle_charge_failure(db, toss, email_sender, sub, service, payment,
billing_key="", exc=exc, ...)
return
⚠️중요: 활성 구독이 있는 카드를 비활성화해도 구독 상태는 즉시 바뀌지 않습니다. 다음 자동결제 시도에서 실패 처리되어 PAST_DUE → (재시도 소진 시) SUSPENDED로 이행합니다.
3-2. 활성 구독이 있는 카드 삭제 차단
삭제 차단 상태 집합(app/services/cards.py:47):
CARD_DELETE_BLOCKING_STATUSES = frozenset({
SubscriptionStatus.TRIAL, SubscriptionStatus.ACTIVE,
SubscriptionStatus.PAST_DUE, SubscriptionStatus.SUSPENDED,
SubscriptionStatus.EXTENDED,
})
| 구독 상태 | 카드 삭제 |
|---|---|
| TRIAL / ACTIVE / PAST_DUE / SUSPENDED / EXTENDED | 차단 — ConflictError |
| CANCELED / EXPIRED | 허용 — 삭제 전 해당 구독의 card_id를 NULL로 |
| 구독 없음 | 허용 |
💡참고:
subscriptions.card_idFK는RESTRICT입니다. 그래서 CANCELED/EXPIRED 구독은 카드 삭제 전에card_id를 NULL로 풀어 FK 위반을 피합니다(cards.py:328-334).
3-3. 에러 요약
| 조건 | 예외 | HTTP |
|---|---|---|
customer_key 형식 오류 |
InputValidationError |
422 |
external_user_id 빈값/255자 초과 |
InputValidationError |
422 |
| 동시 첫 등록 경쟁(유니크 위반) | ConflictError |
409 |
| 토스 빌링키 발급 실패 | TossError(전파) |
4xx/5xx |
| 카드 미등록(조회/삭제) | NotFoundError |
404 |
| billing-active 구독이 카드 참조 중(삭제) | ConflictError |
409 |
3-4. best-effort 빌링키 삭제
교체·삭제 시 옛 빌링키를 safe_delete_billing_key(toss, billing_key)로 지웁니다(app/services/payment_utils.py:24).
async def safe_delete_billing_key(toss, billing_key) -> bool:
try:
await toss.delete_billing_key(billing_key)
return True
except TossError as exc:
if exc.http_status == 404:
return True # 이미 삭제됨 → 성공 간주
logger.warning("빌링키 삭제 실패(토스에 키 잔존 가능): ...")
return False
실패해도 카드 교체·삭제 커밋은 이미 완료되어 유효합니다. 실패 시 WARNING 로그만 남고 토스에 고아 키가 남을 수 있습니다.
4. 감사 로그 & 알림
| 이벤트 | action |
actor | 알림 상수 |
|---|---|---|---|
| 신규 등록 | card.register |
SERVICE | EVENT_CARD_REGISTERED |
| 교체 | card.replace |
SERVICE | EVENT_CARD_REPLACED |
| 삭제 | card.delete |
SERVICE | EVENT_CARD_DELETED |
| 활성화 | card.activate |
USER | EVENT_CARD_ACTIVATED |
| 비활성화 | card.deactivate |
USER | EVENT_CARD_DEACTIVATED |
모든 카드 이벤트는 _card_audit_detail(cards.py:77)로 동일한 상세(external_user_id, service_id, 마스킹 card_number, issuer)를 남깁니다. 빌링키 암호문·해시는 감사로그에 넣지 않습니다.
알림은 best-effort이며(_notify_card, cards.py:58), notifier가 없거나 서비스 알림 URL 미등록이면 조용히 건너뜁니다.
5. 유지보수 팁
- billingKey 노출을 막으려면:
CardResponse.from_model()만 사용하세요.card_info(마스킹)만 반환하고 암호문은 절대 포함하지 않습니다. - 삭제 차단 상태를 바꾸려면:
app/services/cards.py:47CARD_DELETE_BLOCKING_STATUSES를 수정하세요. 여기에 든 상태의 구독이 카드를 참조하면 삭제가 차단됩니다. - 비활성 카드 차단 동작을 바꾸려면: 각 결제 경로의
is_active검사(3-1 표)를 함께 보세요. 자동연장은renewals.py:413, 생성은subscriptions.py:210, 수동결제는subscriptions.py:387입니다. - 빌링키 복호화 실패(
InvalidTag):ENCRYPTION_KEY환경변수가 바뀌지 않았는지 확인하세요. 키가 달라지면 기존 암호문을 복호화할 수 없습니다. - 카드 없이 구독 시도:
POST /api/v1/subscriptions전에 반드시POST /api/v1/cards로 카드를 먼저 등록해야 합니다. 미등록이면 구독 생성이 404NOT_FOUND로 거부됩니다(subscriptions.py:207). - 서비스 삭제 제약:
cards.service_idFK가RESTRICT라 카드가 있는 서비스는 DB 레벨에서 삭제가 차단됩니다.
🔗함께 보기: 카드가 실제로 어떻게 자동결제에 사용되는지는 구독 기능의 자동연장(스케줄러) 절을 보세요.