[Froppy 개발기 1편] 기획 & 아키텍처 — Flutter + NestJS + Cloudflare를 선택한 이유
2시간 전
▣Froppy2개 기록▲ 목록 접기
[Froppy 개발기 1편] 기획 & 아키텍처
기술 스택을 선택하는 건 항상 트레이드오프다.
완벽한 선택은 없고, 그 시점의 나에게 맞는 선택이 있을 뿐이다.
아이디어의 출발점
2025년 어느 날, 서브컬처 스포츠(비인기종목)를 후원하고 관련 사업을 하는 친구와
이야기를 나누다 충격적인 말을 들었다.
"프리다이빙 세계 최고 선수가 한국인인데 아무도 모른다."
프리다이빙 국내외 정상급 선수지만, 생계는 강습으로 유지한다.
훈련비, 장비비, 대회 출전 비용 — 이 모든 걸 강습 수입으로 감당하면서
국내외 대회를 뛰고 있다.
스폰서가 없다. 언론이 없다. 대중이 모른다.
"이 사람이 세계에서 제일 잘 하는데 왜 아무도 모르지?"
그날 대화는 거기서 끝나지 않았다.
비슷한 선수가 한둘이 아니라는 것,
비인기종목 선수들 대부분이 이런 구조 속에 있다는 것.
그래서 떠올렸다. "팬이 선수를 직접 후원하고, 선수는 훈련 과정을 콘텐츠로 공유하는 플랫폼."
이게 Froppy의 시작이었다.
무엇을 만들어야 하는가
MVP 기준으로 필요한 기능을 정리했다.
선수(공급자) 입장
- 회원가입 / 프로필 등록
- 훈련 영상, 사진, 게시글 업로드
- 멤버십(구독) 상품 개설
- 후원 수령 및 내역 조회
- 팬과의 실시간 채팅
후원자(사용자) 입장
- 선수 검색 및 팔로우
- 콘텐츠 피드 열람
- 1회성 후원 결제
- 멤버십 정기 구독
- 실시간 채팅
운영자(Admin) 입장
- 회원/콘텐츠/결제 관리
- 법적 문서(약관, 개인정보처리방침) 관리
- 분쟁 케이스 처리
- 인프라 사용량 모니터링
기능 목록만 봐도 이건 단순한 사이드 프로젝트가 아니었다.
결제, 영상, 실시간 채팅, 법적 동의 시스템까지.
풀스택으로 꽤 묵직한 서비스였다.
기술 스택 선택
Frontend — Flutter
Flutter를 선택한 이유는 명확했다.
"웹과 모바일을 하나의 코드베이스로."
1인 개발에서 Android, iOS, Web 세 벌의 코드를 관리하는 건 현실적이지 않다.
Flutter는 단일 코드베이스로 세 플랫폼을 커버할 수 있다.
물론 현실은 조금 달랐다. (이건 2편에서 자세히 다룬다.)
Flutter
├── 상태관리: Provider
├── 라우팅: GoRouter
├── HTTP: Dio + PrettyDioLogger
├── 소켓: socket_io_client
├── 결제: portone_flutter_v2 + flutter_inappwebview
├── 영상: video_player + youtube_player_flutter
├── 에디터: flutter_quill (리치 텍스트)
├── 이미지: image_picker, cached_network_image
└── 알림: flutter_local_notifications
상태관리 라이브러리는 Riverpod나 Bloc 대신 Provider를 선택했다.
학습 곡선보다 빠른 구현이 우선이었고, 규모가 크지 않은 단계에서는 Provider로 충분했다.
라우팅은 GoRouter. Flutter 공식 권장 라이브러리이고,
웹의 URL 기반 딥링크와 모바일의 named route를 통일된 방식으로 처리할 수 있었다.
Backend — NestJS
백엔드 언어 선택지는 크게 세 가지였다.
| 선택지 | 장점 | 단점 |
|---|---|---|
| NestJS (TypeScript) | 구조적, 타입 안전, 풍부한 생태계 | 보일러플레이트 많음 |
| FastAPI (Python) | 빠른 개발, AI 연동 용이 | 타입 시스템 약함 |
| Express (Node.js) | 자유로움, 가볍다 | 구조를 스스로 잡아야 함 |
NestJS를 선택한 핵심 이유는 강제된 구조 때문이었다.
15개 이상의 도메인이 생길 게 예상되는 상황에서,
Express처럼 자유로운 구조는 오히려 부담이었다.
NestJS의 모듈 시스템은 각 도메인을 명확하게 분리하고,
Guard, Interceptor, Decorator 같은 크로스커팅 관심사를 깔끔하게 처리해줬다.
NestJS
├── ORM: TypeORM + PostgreSQL
├── 인증: Passport.js (JWT, Google, Kakao, Naver)
├── 소켓: @nestjs/websockets + socket.io
├── 결제: @portone/server-sdk
├── 파일: Multer + Cloudflare (Stream, R2, Images)
├── 스토리지: @supabase/supabase-js
├── AWS SDK: @aws-sdk/client-s3 (R2 연동)
├── 스케줄러: @nestjs/schedule
└── 검증: class-validator + class-transformer
TypeORM을 선택한 이유는 데코레이터 기반 엔티티 정의가 NestJS와 찰떡이었기 때문이다.
Prisma도 고려했지만, 당시 NestJS + TypeORM 조합의 레퍼런스가 더 많았다.
인프라 — Supabase + Cloudflare + Railway
초기 스타트업의 가장 큰 제약은 비용이다.
AWS로 풀 구성하면 월 수십만 원이 나온다.
나는 가능한 한 무료 티어와 종량제로 버티는 전략을 택했다.
PostgreSQL 전략 (개발 vs 프로덕션 분리)
개발: 로컬 PostgreSQL
→ 빠른 스키마 변경, 자유로운 실험
프로덕션: Supabase
→ 무료 500MB DB, 파일 스토리지, 실시간 기능 보조
개발 환경에서는 로컬 PostgreSQL을 사용했다.
매번 클라우드 DB에 붙으면 레이턴시도 있고, 비용도 나온다.
.env.development / .env.production 파일로 환경을 완전히 분리했다.
Cloudflare 3종 세트
Cloudflare를 이렇게 많이 쓰게 될 줄 처음에는 몰랐다.
Cloudflare Stream → 영상 업로드 & HLS 스트리밍
Cloudflare R2 → 채팅 미디어 파일 저장 (S3 호환 API)
Cloudflare Images → 프로필/콘텐츠 이미지 CDN + 서명 키 보안
Cloudflare Stream은 영상 스트리밍 서비스 중 가격 대비 가장 합리적이었다.
S3 + CloudFront로 구성하는 것보다 설정이 단순하고, HLS 변환을 자동으로 해준다.
Cloudflare R2는 S3 호환 API를 제공하므로 @aws-sdk/client-s3를 그대로 사용할 수 있다.
Egress 비용이 없다는 점이 채팅처럼 파일 트래픽이 많은 용도에 적합했다.
Cloudflare Images는 이미지 CDN + Signed URL로 무단 직접 접근을 막을 수 있었다.
Railway — NestJS 배포
Railway는 GitHub 연동 후 push하면 자동 배포되는 PaaS다.
초기 설정이 매우 간단하고, 환경변수 관리도 편하다.
Heroku 대안으로 많이 쓰이는데, 무료 플랜 제한이 생긴 후에도 소규모 서비스엔 충분했다.
전체 시스템 아키텍처
┌─────────────────────────────────────────────────────────┐
│ Client Layer │
│ │
│ Flutter Web (Cloudflare Pages) │
│ Flutter Mobile (Android / iOS) │
└───────────────────────┬─────────────────────────────────┘
│ HTTPS / WSS
┌───────────────────────▼─────────────────────────────────────┐
│ NestJS Backend │
│ (Railway 배포) │
│ │
│ Auth Module → JWT + OAuth (Google/Kakao/Naver) │
│ Users Module → 프로필, 팔로우 │
│ Posts Module → 피드, 게시글, 댓글, 좋아요 │
│ Payments Module → 후원 결제 (PortOne) │
│ Memberships Module→ 멤버십 상품 관리 │
│ Subscriptions → 정기 구독 관리 │
│ Chat Module → WebSocket Gateway + R2 │
│ Notifications → 실시간 알림 │
│ Legal Documents → 약관, 개인정보처리방침 │
│ Admin Module → 운영 어드민 │
│ Cloudflare Module → Stream / Images API 연동 │
│ R2 Module → Cloudflare R2 파일 처리 │
│ Inquiry Module → 고객 문의 │
└────┬──────────────┬─────────────────┬───────────────────────┘
│ │ │
┌────▼─────┐ ┌─────▼──────┐ ┌────▼──────────────────────┐
│ Supabase│ │Cloudflare │ │ PostgreSQL (Supabase) │
│ Storage │ │Stream / R2│ │ (프로덕션 DB) │
│ (파일) │ │/ Images │ │ │
└─────────┘ └────────────┘ └────────────────────────────┘
엔티티(데이터 모델) 구성
TypeORM 엔티티 기준으로 최종 25개의 테이블이 생겼다.
User → 회원
File → 업로드 파일 메타데이터
Post / PostFile → 게시글 / 게시글-파일 관계
Comment → 댓글
Like / CommentLike → 좋아요
Bookmark → 북마크
Follow → 팔로우 관계
PostReport / CommentReport → 신고
Sponsorship → 후원 결제
PaymentAccount → 정산 계좌
Membership → 멤버십 상품
Subscription → 구독 관계
SubscriptionPayment → 구독 결제 내역
ChatRoom → 채팅방
ChatParticipant → 채팅 참여자
ChatMessage → 채팅 메시지
ChatAccessRoom → 채팅 접근 권한
Notification → 알림
LegalDocument → 법적 문서 (약관 등)
LegalConsent → 동의 이력
DisputeCase → 분쟁 케이스
CustomerInquiry → 고객 문의
InfraCost → 인프라 비용 추적
InfraUsageSnapshot → 인프라 사용량 스냅샷
지금 보면 많다 싶지만, 하나씩 기능을 추가하다 보면 자연스럽게 이렇게 된다.
처음부터 이 구조를 설계한 게 아니라, 기능이 생길 때마다 엔티티가 붙어갔다.
개발 환경 구성
로컬 개발 환경
├── Flutter Web: localhost:8080 (Chrome)
├── NestJS: localhost:3000
├── PostgreSQL: localhost:5432
└── 환경변수: .env.development
프로덕션 환경
├── Flutter Web: Cloudflare Pages
├── NestJS: Railway
├── PostgreSQL: Supabase
└── 환경변수: Railway 환경변수 패널
package.json의 스크립트 구성:
"start:development": "set NODE_ENV=development && nest start --watch",
"start:staging": "set NODE_ENV=staging && nest start",
"start:production": "set NODE_ENV=production && node dist/main"
환경별로 다른 .env 파일을 로드하는 방식으로,
로컬에선 로컬 DB, 프로덕션에선 Supabase DB를 각각 바라보게 했다.
이 선택들을 돌아보며
잘 된 선택:
- NestJS 모듈 구조: 15개 도메인이 생겼을 때도 각 모듈이 명확히 분리돼 있어서 유지보수가 편했다
- Cloudflare R2: S3 호환 API 덕분에 AWS SDK를 그대로 쓸 수 있었고, egress 비용이 없었다
- 환경 분리 전략: dev/prod 환경을 완전히 분리해서 로컬 개발이 항상 빠르고 안전했다
아쉬운 선택:
- TypeORM: Prisma에 비해 마이그레이션 관리가 불편하다. 스키마 변경이 잦은 초기 단계에서 Prisma가 더 좋았을 것 같다
- Provider 상태관리: 앱이 복잡해지면서 Provider 트리가 깊어졌다. Riverpod나 Bloc이 장기적으로 더 나았을 수 있다
다음 편 예고
다음 편에서는 Flutter로 Web과 Mobile을 동시에 개발하면서 만난 현실적인 문제들을 다룬다.
dart:html과 stub 파일로 플랫폼을 분기하는 방법,
결제창 같은 플랫폼 의존적인 기능을 처리하는 방법 등.
이론과 실전의 차이가 가장 크게 느껴진 부분이었다.
이 시리즈는 총 8편으로 구성됩니다.
- [1편] 기획 & 아키텍처 — Flutter + NestJS + Cloudflare를 선택한 이유 ← 현재 글
- [2편] Flutter로 Web과 Mobile 동시 개발하기
- [3편] NestJS로 스타트업 백엔드 설계하기
- [4편] 소셜 로그인 3종 세트 — Google / Kakao / Naver OAuth 연동 실전
- [5편] 결제 시스템 구축 — Toss Payments & PortOne 이중 연동기
- [6편] Supabase + Cloudflare로 저비용 인프라 구성하기
- [7편] 실시간 채팅 — WebSocket Gateway + Cloudflare R2
- [8편] 피벗 — 1년간 만든 서비스를 접는 결정

