Skip to content

feat: Payment repo를 backend repo에 병합#43

Merged
MU-Software merged 19 commits into
mainfrom
feature/migrate-purchase-repo
May 12, 2026
Merged

feat: Payment repo를 backend repo에 병합#43
MU-Software merged 19 commits into
mainfrom
feature/migrate-purchase-repo

Conversation

@MU-Software
Copy link
Copy Markdown
Member

작업 배경

  • 동일 개발자들이 개발하여 코드베이스가 대부분 유사하고, 덕분에 동일 역할 코드가 양측에 중복 구현된 경우가 많음
    • logger나 serializer 등등
  • 별도로 repo가 있다보니, 인프라에서 각 서비스별 설정을 2벌로 작업해야 함
  • API 관리가 어려워짐 + 일관성 하락
  • 기능이 양측에 분리되어 있어, 통합 개발이 어려움
    • 주문 관련 알림 시스템
    • 티켓에 발표자나 후원사 정보들을 연결시키기 어려움
    • 운영진 정보를 별도로 관리해야 함

이에 payment repo를 backend repo에 병합합니다.

주요 변경 사항

1. purchase_sharedapp/core 흡수

  • PortOne v1 client (core/external_apis/portone/)
  • TOTP / dateutil / str util (core/util/)
  • API Key 인증 (core/authn/api_key.py) + 권한 (core/authz/api_key.py)

2. python-korea-payment 의 결제 도메인 → app/shop/* sub-app

  • shop/order — Order, OrderProductRelation, OrderProductOptionRelation, SingleProductCart, CustomerInfo
  • shop/product — Product, OptionGroup, Option, Tag, Category, CategoryGroup
  • shop/payment_history — PortOne v1 webhook + 상태 전이 검증

3. UserExt / 인증 통합

  • UserExt.unique_id 추가 (QR scancode 식별자)
  • django-allauth 도입 (google_oauth2 와 병행)

4. ScanCode (QR 코드) 도메인

  • core/scancode_mixin.ScanCodeMixinUserExt / Order / OrderProductRelation 공통
  • shop/order/views/scancode.py 단일 viewset, prefix dispatch (user: / order: / opr:)

5. 어드민 (admin_api/views/shop/)

  • 주문 조회 / CSV import-export / 전체 환불 (TOTP 검증)
  • 상품 / 옵션 그룹 / 옵션 / 태그 CRUD
  • 주문 알림 미리보기 + 발송 (Celery 기반, transaction.on_commit enqueue)

6. 사용자 / 외부 API

  • internal_api/ — desk_support (등록 데스크 환불 등)
  • shop/patron.py — 후원자 목록 공개 API

7. 보안 / 정합성

  • 결제 webhook 멱등성 (state machine + select_for_update)
  • 환불 race 직렬화 (Order aggregate root row lock)
  • TOTP 정책 통일 (serializer context 기반)
  • 기타 다수 (cart validation 0원 차단, currency 검증, custom_response 무결성 등)

후속 작업 (별도 PR)

  • Data migration: legacy DB → 본 backend DB. user_id 매핑 (auto/manual/shifted) +
    shop 도메인 + simple-history 복사. cutover 시 LEGACY_DATABASE_* env var 설정 후 실행.
  • Cleanup: cutover 검증 완료 후 data migration 파일/설정 no-op 화.
  • payment repo archive: data 정합성 확인 + cutover 안정화 후.

Comment on lines +44 to +46
queryset = (
Order.objects.filter_has_payment_histories()
.filter(models.Exists(OrderProductRelation.objects.filter(order=models.OuterRef("pk"))))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 django queryset에 OuterRef라는 기능도 있었군요 신기하네요...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 우선순위 낮은 코멘트이긴 한데요..! queryset이 길어서 하위 QuerySet (SubQuerySet..? 명칭을 뭐라고 하는지 정확히 모르겠네요ㅠㅠ) 같은 걸 사용하면 가독성이 조금 더 좋아질 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e469957에서 쿼리를 분리했습니다!

Comment on lines +153 to +167
@staticmethod
def _lock_or_promote_order(obj_id: str) -> Order:
"""Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격.

동시 webhook race 시: 첫 호출이 cart lock + to_order() commit 후, 두 번째 호출은
cart 가 hard_delete 된 상태로 lock 해제됨 → Order 재조회에서 승격된 Order 발견.
"""
if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first():
return order
if cart := SingleProductCart.objects.select_for_update().filter_active().filter(id=obj_id).first():
return cart.to_order()
# 첫 lock 시 cart 가 다른 webhook 에 의해 promote 된 경우 — Order 재조회.
if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first():
return order
raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.ORDER_NOT_FOUND, code="forgery")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 여기서 Order이나 SingleProductCart를 반환하는데, 혹시 SingleProductCartOrder 모델이 어떤 차이가 있는지 간단하게 코멘트 달아주심 나중에 다른 분들이 보시기에 조금 덜 헷갈릴 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

542b44e에서 추가 설명 추가했슴다, 다만 요건 나중에 구조 문서를 별도로 만드는 쪽이 확실하겠네유

Comment thread app/shop/serializers/cart_validation.py Outdated
@@ -0,0 +1,606 @@
import enum
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거는 아주 사소한 사항이긴 한데, 파일이 길어서 app/shop/serializers/cart/ 디렉토리 안에다가 Tag, Option, Product 등등으로 나눠도 좋을 것 같아요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

20fa5e9에서 분리했슴다ㅎ 이게 훨 낫네여!

Comment thread app/shop/order/views/scancode.py Outdated
Comment on lines +39 to +40
if not any(o.current_status != PaymentHistoryStatus.refunded for o in orders):
raise _ScanCodeError(msg="최근 6개월 이내에 결제한 주문이 없습니다.", code=status.HTTP_403_FORBIDDEN)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

환불한 사람도 여기에 해당될 수 있을 것 같아서, 메시지를 "최근 6개월 이내에 결제된 유효한 주문이 없습니다(환불 완료 또는 주문 없음)" 로 추가해도 좋을 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그게 확실히 더 명확하겠네요, 094c09f에서 수정 완료했슴다ㅎ

Comment thread app/core/authn/api_key.py
Comment on lines +19 to +23
def _get_or_create_api_key_user(api_key: str) -> "UserExt":
from user.models import UserExt

username = f"API_KEY_USER_{api_key.upper()}"
email = f"api_key_user_{api_key.lower()}@pycon.kr"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(가능성이 적어서 우선순위는 낮아 보이긴 합니다..!) 혹시 api key가 중간에 바뀌게 된다면 이전 api key로 만들어진 유저들을 식별하기 어려울 수도 있을 것 같아요.

어떤 방법이 좋을지는 모르겠는데... APIKey 모델을 만드는 것도 하나의 대안 같습니다!

Copy link
Copy Markdown
Member Author

@MU-Software MU-Software May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Key는 저희 서비스 (ex: 등록 데스크)를 호출하는 타 서비스가 로그인 인증 대신 사용하도록 추가한 것이긴 하거든요, 즉 서비스별로 API Key가 발급되는 것이 의도한 바이긴 합니다! 그리고 key rotation이 발생하면 해당 타 서비스가 접근 못하게 되는 것이 오히려 의도한 바입니다, API Key가 유출되면 접근을 막는 것이 맞아 보여서요...! 다만 key rotation 후 기존 key로는 유저를 완전 추측하지 못하게 되는 것은 좀 아쉬운 점이긴 하네요, 이 부분에서는 확실히 테이블을 추가하고, simple history를 묶는 것이 낫겠습니다 🤔

다만 요 PR 내용이 그렇잖아도 무지막지하게 긴 지라(...) 그리고 API Key가 당장 사용되지는 않으므로 (사실 payment에 기능을 그대로 옮겨온 것에 좀 더 가깝긴 합니다 😅 다만 개인적으로 개발 중인 등록 데스크 쪽에서 사용 예정이긴 한지라 곧 또 작업해야 해서 지금 같이 옮긴 것도 있긴 합니당) 요건 별도 PR에서 진행하겠습니다 🙏

Comment on lines +80 to +81
@transaction.atomic
def refund(self) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 기능상 문제는 없어 보이지만, Serializer에서 직렬화 외의 실제 환불 작업을 담당하게 되면 직관적이지 않을 수도 있을 것 같아요.

Order 자체의 메소드로 빼거나, 아예 viewset에서 별도의 api를 만드는 것도 괜찮을 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 좀 고민되긴 합니다, refund를 model 메서드로 옮기면, PortOne HTTP 호출 + state machine 전이 + simple_history 기록까지 전부 옮겨가게 되는거라, ORM model이 외부 I/O 책임을 가지게 되어서 비대 패턴이 될 것 같거든요. 별도의 파일로 분리할 것이 아니라면 여기가 그나마 낫지 않을까......싶긴 합니다 🤔

Copy link
Copy Markdown
Contributor

@earthyoung earthyoung left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!

@MU-Software MU-Software merged commit 5e79385 into main May 12, 2026
1 check passed
@MU-Software MU-Software deleted the feature/migrate-purchase-repo branch May 12, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants