결혼 선물 목록을 자선 기부 플랫폼으로 전환하기
요약
결혼 선물 목록을 자선 기부 플랫폼으로 전환한 사례를 통해, 수동적인 프로토타입을 확장 가능한 멀티 테넌트 플랫폼으로 구축하는 과정을 다룹니다. Stripe 결제 연동, 추가 전용 원장(append-only ledger) 시스템, 행 수준 멀티테넌시 등 금융 소프트웨어 설계의 핵심 원칙을 설명합니다.
핵심 포인트
- Stripe를 활용한 검증된 자선 단체 결제 시스템 구축
- 데이터 무결성을 위한 추가 전용(append-only) 원장 설계
- 보안을 위해 민감한 은행 정보를 저장하지 않는 설계 원칙
- Django와 React를 활용한 모듈형 모놀리스 아키텍처
요약 (TL;DR) — 실제 결혼식에서 하객들에게 선물 대신 자선 단체 기부를 요청했습니다. 이 한 커플을 위한 프로토타입은 _Love That Gives Back_로 발전했습니다. 이는 누구나 어떤 축하 행사든 레지스트리(registry)를 생성할 수 있는 멀티 테넌트 (multi-tenant) 플랫폼입니다. 하객들은 Stripe를 통해 검증된 (verified) 자선 단체에 기부하고, 관리되는 공개 메시지를 남길 수 있으며, 모든 유로화는 추가 전용 원장 (append-only ledger)에 기록됩니다. 원래의 결혼식은 현재 라이브 플래그십 캠페인으로 운영 중입니다.
시작점
1년 전, 전통적인 선물 목록 대신 Anna와 Alan이라는 한 커플은 하객들에게 자신들에게 중요한 세 곳의 자선 단체인 Mary's Meals, Operation Smile, 그리고 Xingu Vivo (아마존 분지의 풀뿌리 운동)에 기부해 줄 것을 요청했습니다. 각 선택에는 이야기가 있었습니다: Mary's Meals가 시작된 스코틀랜드 하이랜드 방문, 구순열을 가지고 태어난 형제, 현금 대신 기부로 "결제한" 결혼반지 같은 이야기들 말이죠.
v0 사이트는 제 역할을 했지만, 임시방편으로 간신히 유지되는 수준이었습니다:
- 하객들이 Revolut이나 은행을 통해 수동으로 돈을 송금하면, 관리자가 직접 "확인 (confirm)" 버튼을 클릭해야 했습니다.
- 은행 정보가 모델 내에 평문 (plaintext)으로 저장되었습니다.
- 방명록은 정적인 CSV 파일이었습니다.
- 차트는 약 8개의 무거운 의존성 (dependencies)을 끌어오는 서버 렌더링 방식의 matplotlib PNG 파일이었습니다.
이 방식은 _단 한 커플_에게는 작동했습니다. 흥미로운 질문은 이것이었습니다: 만약 누구라도 생일, 추모식, 혹은 어떤 축하 행사에서든 수동적인 자금 처리 없이, 그리고 플랫폼이 은행 번호에 전혀 손을 대지 않고도 이 일을 할 수 있다면 어떨까?
내가 구축한 것
돈을 다루는 데 있어 마땅히 가져야 할 엄격함을 갖춘 **모듈형 모놀리스 (modular monolith)**입니다.
| v0 프로토타입 | v2 플랫폼 | |
|---|---|---|
| 테넌시 (Tenancy) | 하나의 하드코딩된 커플 | 다수의 캠페인 (Campaigns) + 자선 단체 (Charities), 행 범위 지정 (row-scoped) |
| ... | ||
| 스택 (Stack): Django 5 + DRF (운영 환경은 Postgres, 로컬은 SQLite), 프론트엔드는 React 19 + Vite, 결제는 Stripe 호스팅 Checkout + Connect를 사용하며, 의도적으로 가볍게 구성된 AWS 환경에 배포 가능합니다. |
내가 타협하지 않은 불변량 (invariants)
돈을 다루는 소프트웨어는 대부분 무엇을 _하지 않을 것인가_에 관한 것입니다. 저는 첫날부터 이 사항들을 적어두고 이를 중심으로 가드레일(guardrails)을 구축했습니다:
- 돈은 오직 검증된
Charity(자선 단체)에만 도달할 수 있습니다. 개인에게 지급하는 것은 불가능합니다. - 원본 은행/카드 데이터를 절대 저장하지 않습니다. 지급 대상의 신원은 Stripe 계정 ID로만 식별하며, 그게 전부입니다.
LedgerEntry(원장 항목)가 돈에 대한 신뢰할 수 있는 단일 원천(source of truth)입니다. 이는 추가 전용(append-only) 방식이며, 매일 Stripe과 대조하여 조정(reconciled)됩니다.Donation(기부) 항목을 수정하여 돈의 상태를 변경하는 일은 절대 없습니다.- 모든 공개 사용자 콘텐츠는 **중재 가능(moderatable)**해야 하며, 동의 절차를 거쳐야 합니다.
- 행 수준의 멀티테넌시(Row-level multitenancy)는 단순히 시리얼라이저(serializers)뿐만 아니라 DRF의
get_queryset에서 강제됩니다. - 공개 API 응답에 PII(개인 식별 정보, 예: 기부자 이메일)를 포함하지 않습니다.
- PCI 준수: SAQ-A 수준을 유지합니다. Stripe이 호스팅하는 Checkout만 사용하며, 카드 데이터는 백엔드에 절대 닿지 않습니다.
- 모든 결제 작업에 **멱등성(Idempotency)**을 보장합니다. 이벤트 ID를 통해 웹훅(webhook)을 확인하고 중복을 제거(dedupe)합니다.
가장 많은 코드 구조를 결정지은 것은 3번입니다. 기부가 "실제"가 되는 것은 어떤 행(row)이 confirmed(확인됨)라고 표시되었기 때문이 아닙니다. 서명된 Stripe 웹훅이 도착하고, 중복이 제거되었으며, OutboxEvent와 동일한 트랜잭션 내에서 원장 항목(ledger entry)을 작성했기 때문에 실제가 되는 것입니다:
# 동일한 DB 트랜잭션: 원장(ledger) + 아웃박스(outbox), 영수증/이메일 발송을 위해 나중에 처리됨
with transaction.atomic():
LedgerEntry.objects.create(donation=donation, amount=amount, ...)
...
워커(manage.py drain_outbox)가 아웃박스를 비워(drain) 영수증과 감사 이메일을 보냅니다. 이메일 전송 서비스가 다운되더라도 돈에 대한 기록은 여전히 정확하며, 부수 효과(side effects)는 재시도됩니다. 즉, 영수증이 누락되거나 중복 결제가 발생하지 않습니다.
가장 뼈아픈 교훈: 타인의 UI를 테스트하지 마세요
처음에 제 엔드투엔드(end-to-end) 테스트는 CI 환경에서 Playwright를 사용하여 Stripe의 호스팅된 Checkout 페이지를 직접 구동했습니다. Stripe의 iframe 안에 4242… 테스트 카드 번호를 입력하는 방식이었죠. 이 테스트는 만성적으로 불안정(flaky)했는데, 결국 그 _이유_를 깨달았습니다. Stripe은 헤드리스(headless) 또는 데이터센터 트래픽을 감지하면 에이전트 식별 확인 절차를 요구하며 결제를 완료하지 않습니다. CI 테스트에서 해당 페이지를 자동화하는 것은 제 앱이 아니라 Stripe을 테스트하는 것이며, 이는 제가 관리할 권한이 없는 PCI SAQ-A 영역입니다.
그래서 저는 이를 분리했습니다:
- **CI 테스트 (CI test)**는 기부 양식을 채우고, 백엔드가 유효한
checkout.stripe.com세션을 생성했는지 확인하여(파라미터 및 멱등성 증명),DEBUG-및-E2E_TEST_HOOKS-게이트가 적용된 엔드포인트를 통해 **실제 웹훅 코드 경로 (real webhook code path)**를 거쳐 기부를 확인한 뒤, 방명록 대기 → 호스트 승인 → 공개로 이어지는 브라우저 흐름을 완료합니다. - 라이브 UI 워크스루 (Live UI walk) (전체 카드 입력 과정)는 로컬 전용
@live테스트로 유지됩니다.
CI는 몇 분씩 걸리며 불안정하던 상태에서 결정론적인 약 4초의 실행 시간으로 개선되었습니다. 이 원칙은 일반화될 수 있습니다: 당신의 신뢰 경계 (trust boundary)에서, 제3자의 UI가 아니라 당신이 제어하는 계약 (contract)을 검증하십시오.
돈을 허공에 날리지 않고 배포하기
배포 대상은 의도적으로 저렴하게 설정했습니다. 제 Terraform에 AWS 가격 책정 도구를 적용해 본 결과, 전체 스택 비용은 월 $50–60 정도입니다:
- ECS Fargate (0.25 vCPU / 0.5 GB)를 퍼블릭 서브넷 (public subnets)에 배치 — NAT 게이트웨이 미사용 (이것만으로도 월 약 $32를 절약합니다)
- RDS
db.t4g.microPostgreSQL, 단일 가용 영역 (Single-AZ) ($0.017/hr) - ALB 1개, SPA를 위한 S3 + CloudFront, 비밀값 관리를 위한 SSM Parameter Store
예산 가드레일은 리포지토리 내에 명시적인 비목표 (non-goals)로 정의되어 있습니다: NAT 사용 금지, Aurora Serverless v2 사용 금지, 두 번째 ALB 사용 금지. 저는 전체 라이브 배포 과정을 Terraform 출력값에서 직접 읽어오는 infra, acm, ssm, image, migrate, frontend, webhook, verify와 같은 서브 명령어를 포함한 하나의 멱등적 스크립트(scripts/phase1-golive.sh)로 묶었습니다.
실제 모습
플래그십 프로젝트가 시드된 상태에서,
- Repo: https://github.com/alanmaizon/love
- Live demo: <!-- [https://www.lovethatgivesback.com](https://www.lovethatgivesback.com) -->
이 글에서 단 한 가지만 기억해야 한다면 이것입니다. 결제 프로세서 (Payment Processor)를 기반으로 구축할 때는, 카드 데이터와 컴플라이언스 (Compliance) 관리는 해당 프로세서가 호스팅하는 영역에 맡기십시오. 그리고 여러분의 시스템은 추가 전용 원장 (Append-only ledger), 멱등적 웹훅 (Idempotent webhooks), 그리고 트랜잭션 아웃박스 (Transactional outbox) 패턴을 통해 증명 가능한 수준으로 정확하게 만드십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기