Cloudflare에서 AI 배경 제거 도구를 구축하며 배운 점들
요약
Cloudflare 환경에서 AI 기반 이미지 배경 제거 SaaS인 CutoutKit을 구축하며 겪은 아키텍처 설계 경험을 공유합니다. 인증, 결제, API 보안 및 개인정보 보호를 고려한 실전적인 구현 패턴을 다룹니다.
핵심 포인트
- Cloudflare Pages와 D1을 활용한 서버리스 아키텍처 설계
- 제3자 API 키 노출 방지를 위한 서버 측 프록시 구현
- 개인정보 보호를 위한 데이터 비저장 원칙 및 정책 수립
- 사용량 제한, 결제, 웹훅 신뢰성 등 SaaS 필수 기능 구현 패턴
최근 저는 이미지 배경을 제거하고 투명한 PNG를 반환하는 작은 웹 앱인 CutoutKit을 출시했습니다.
인터페이스는 의도적으로 단순하게 설계되었습니다: 이미지를 업로드하고, 몇 초간 기다린 후, 결과물을 다운로드하는 방식입니다. 하지만 이 흐름 뒤에 숨겨진 구현 과정은 그리 간단하지 않았습니다. 프로토타입 단계를 넘어선 후에는 인증 (Authentication), 사용 제한 (Usage limits), 결제 (Payments), 웹훅 신뢰성 (Webhook reliability), 개인정보 보호 경계 (Privacy boundaries), 그리고 Cloudflare에서 안정적으로 실행될 수 있는 아키텍처 (Architecture)가 필요했습니다.
이 포스트는 저에게 가장 유용했던 결정들과 실수들을 다룹니다. 이는 특정 스타터 리포지토리 (Starter repository)를 위한 튜토리얼이 아니라, 다른 API 기반의 SaaS 제품에서도 재사용할 수 있는 일련의 패턴들입니다.
제약 사항이 아키텍처를 형성하다
저는 몇 가지 의도적인 제약 사항을 설정하고 시작했습니다:
- 프론트엔드 (Frontend)는 Next.js, React, Tailwind CSS를 사용합니다.
- 애플리케이션은 Cloudflare Pages와 그 서버 측 런타임 (Server-side runtime)에서 실행됩니다.
- 업로드된 이미지는 애플리케이션에 의해 영구 저장 (Persisted)되지 않습니다.
- 배경 제거는 remove.bg API에 위임합니다.
- Google OAuth를 통해 사용자를 식별합니다.
- Cloudflare D1은 사용자, 세션 (Sessions), 사용량 (Usage), 구매 내역을 저장합니다.
- 유료 액세스는 정기 구독 (Recurring subscriptions) 대신 일회성 크레딧 팩 (One-time credit packs)을 사용합니다.
결과적인 요청 흐름 (Request flow)은 다음과 같습니다:
Browser
|
+--> Next.js UI on Cloudflare
...
중요한 개인정보 보호 세부 사항 하나: "저장되지 않음"이 "브라우저를 절대 떠나지 않음"을 의미하지는 않습니다. 업로드된 파일은 제 서버 엔드포인트 (Server endpoint)를 통해 이미지 처리 제공업체로 전송됩니다. 제 애플리케이션은 원본 이미지나 처리된 이미지를 데이터베이스 (Database), 오브젝트 스토어 (Object store), 또는 디스크 (Disk)에 기록하지 않지만, 프로세서 (Processor)는 요청된 작업을 수행하기 위해 여전히 이미지를 수신합니다. 이러한 차이점은 제품 설명 문구와 개인정보 처리방침 (Privacy policy) 모두에 명시되어야 합니다.
제3자 API 키를 서버 경계 뒤에 유지하기
첫 번째 프로덕션 규칙은 명백하지만 빠른 프로토타입 제작 중에 위반하기 쉽습니다: 비밀 키 (Secret key)가 필요한 유료 처리 API를 브라우저 코드에서 직접 호출하지 마십시오.
브라우저는 동일 출처(same-origin) 서버 엔드포인트로 multipart/form-data 요청을 업로드합니다. 해당 엔드포인트는 Cloudflare secret에서 remove.bg 키를 읽어 업스트림 (upstream) 요청에 추가하고, 이미지 응답을 반환합니다. 이 키는 JavaScript 번들(bundle)에 포함되거나 공개 설정 엔드포인트를 통해 노출되지 않습니다.
또한 프록시(proxy)는 애플리케이션의 규칙을 강제하기에 적합한 장소가 되었습니다:
- 인증된 사용자 요구
- 교차 출처 (cross-origin) 요청 거부
multipart/form-data만 허용- 요청 크기를 22 MB로 제한
- JPEG, PNG, WebP MIME 타입만 허용
- 선택적으로 Turnstile 토큰 검증
- 단기 IP 속도 제한 (rate limit) 적용
- 사용자의 사용 가능한 크레딧(credit) 확인 및 예약
- 업스트림 (upstream) API 요청에 타임아웃 (timeout) 추가
클라이언트 측 검증 (client-side validation)은 여전히 사용자 경험을 개선하지만, 보안 경계 (security boundary)는 아닙니다. 모든 유의미한 규칙은 서버에서 반복됩니다.
비용이 많이 드는 작업을 수행하기 전에 사용량을 예약하십시오
사용량 계산 (usage accounting)은 두 개의 요청이 거의 동시에 도착하기 전까지는 사소해 보입니다.
취약한 구현은 다음과 같은 순서를 따릅니다:
남은 크레딧 읽기
비용이 많이 드는 API 호출
사용량 증가
두 개의 동시 요청이 모두 동일한 남은 크레딧을 확인하고 둘 다 진행될 수 있습니다. 더 나은 패턴은 유료 API를 호출하기 전에 사용량을 예약하는 것입니다:
원자적 (atomically)으로 크레딧 예약
업스트림 (upstream) 프로세서 호출
성공 시 예약 확정
...
데이터베이스 작업과 외부 API 호출이 실제 데이터베이스 트랜잭션 (transaction)을 공유할 수는 없지만, 이 방식은 요청에 작은 트랜잭션과 유사한 생명주기 (lifecycle)를 부여합니다.
실패 경로 (failure path)는 성공 경로만큼 중요합니다. 타임아웃 (timeout), 거부된 파일, 업스트림 (upstream) 장애, 또는 제공업체의 잔액 소진 등으로 인해 사용자에게 비용이 청구되어서는 안 됩니다. 제 핸들러 (handler)에서는 업스트림 (upstream) 실패 시 클라이언트에 정규화된 에러를 반환하기 전에 최선의 노력으로 예약을 해제(release)하도록 처리합니다.
이 패턴은 이미지 생성, 전사 (transcription), 문서 변환, 이메일 검증, 또는 LLM 호출과 같은 모든 종량제 (metered) API 제품에 유용합니다.
에지 배포 (edge deployment)에서의 Google OAuth
Google OAuth는 일반적이지만, 커스텀 도메인 (custom domains)과 프리뷰 도메인 (preview domains)은 날카로운 경계(edge)를 만듭니다. 즉, 리다이렉트 URI (redirect URI)가 정확히 일치해야 합니다.
제 애플리케이션은 단일 정식 애플리케이션 오리진 (canonical application origin)으로부터 콜백 (callback)을 생성합니다:
해당 URI가 Google OAuth 클라이언트 설정에 정확히 나타나야 합니다. 프로토콜 (protocol), 호스트 이름 (hostname), 포트 (port), 경로 (path) 또는 마지막 문자(trailing characters)의 차이는 redirect_uri_mismatch 오류를 발생시킵니다.
로그인 흐름은 수명이 짧은 HTTP-only 쿠키 (HTTP-only cookie)에 저장된 OAuth 상태 (state) 값을 사용합니다. 콜백은 권한 부여 코드 (authorization code)를 교환하기 전에 해당 쿠키를 반환된 상태 값과 비교합니다. Google 프로필을 가져온 후, 앱은 D1에 사용자를 업서트 (upsert)하고 서버 측 세션 (server-side session)을 생성합니다.
브라우저는 보안이 적용된 HTTP-only 쿠키에 서명된 세션 식별자 (session identifier)를 받습니다. 브라우저는 Google 액세스 토큰 (access token)을 받지 않으며, 애플리케이션은 프로필을 가져온 후 해당 토큰을 유지할 필요가 없습니다.
D1은 앱에 필요한 내구성이 있는 제품 데이터만 저장합니다: Google 식별자 (subject identifier), 이메일, 표시 정보, 세션, 사용 이벤트 및 구매 내역입니다.
일회성 크레딧 팩으로 단순화한 과금 모델
저는 자동 구독 (automatic subscriptions) 대신 30일 크레딧 팩 (credit packs)을 선택했습니다. 현재 모델은 소량의 무료 허용량과 두 가지 유료 팩으로 구성됩니다. 구매를 하면 만료일이 지정된 고정된 수량의 크레딧이 추가되며, 정기 결제 (recurring billing)를 생성하지는 않습니다.
PayPal과 Creem을 모두 지원함으로써 결제 제공업체 (payment provider)와 권한 모델 (entitlement model)을 분리할 수 있었습니다. 제공업체는 돈을 수집하지만, 사용자가 이미지를 처리할 수 있는지 여부는 제 자체 데이터베이스가 결정합니다.
일반적인 흐름은 다음과 같습니다:
- 로그인한 사용자가 플랜을 선택합니다.
- 서버는 공개 플랜 ID를 서버 소유의 가격 및 크레딧 수로 매핑합니다.
- 서버는 내부 구매 ID (internal purchase ID)와 함께 체크아웃 (checkout) 또는 주문을 생성합니다.
- D1에 구매를
pending(대기 중) 상태로 기록합니다. - 제공업체가 결제를 완료합니다.
- 검증된 서버 콜백이 구매를 완료로 표시하고 권한 (entitlement)을 부여합니다.
브라우저는 권위 있는 금액이나 크레딧 수량을 절대 제출하지 않습니다. 브라우저는 오직 플랜 식별자 (plan identifier)만을 제출하며, 서버가 해당 플랜의 세부 정보를 조회합니다.
결제 웹훅 (webhooks)을 적대적이고 반복적인 것으로 취급하십시오
결제 성공 페이지는 사용자 경험 (UX) 측면에서 유용하지만, 신뢰할 수 있는 진실의 원천 (source of truth)은 아닙니다. 고객은 탭을 닫거나, 연결을 끊거나, 브라우저 요청을 조작할 수 있습니다. 웹훅 (Webhooks)이 내구성이 있는 확인 채널입니다.
저의 웹훅 핸들러 (webhook handlers)는 네 가지 규칙을 따릅니다:
- 어떤 작업을 수행하기 전에 제공업체의 서명 (signature)을 검증합니다.
- 모든 제공업체 이벤트 ID (event ID)를 저장합니다.
- 이미 완료된 이벤트는 크레딧을 다시 부여하지 않고 성공을 반환하도록 합니다.
- 이벤트를 서버에 의해 생성된 구매와 다시 연결합니다.
이것이 실무적인 형태의 멱등성 (idempotency)입니다. 결제 제공업체는 웹훅을 재시도하며, 여러분의 엔드포인트 (endpoint)는 동일한 이벤트가 여러 번 도착할 수 있다고 가정해야 합니다.
또한 각 웹훅 이벤트에 대한 처리 상태를 저장합니다. 이벤트가 중간에 실패하면, 해당 상태가 가시화되어 마치 도착하지 않았던 것처럼 속이지 않고도 재시도할 수 있습니다.
Cloudflare는 정적 호스팅 그 이상이었습니다
공개 페이지는 정적으로 렌더링되는 반면, 인증 및 이미지 처리 라우트 (routes)는 에지 (edge)에서 실행됩니다. Cloudflare D1은 관계형 상태 (relational state)를 제공하며, 환경 비밀값 (environment secrets)은 제공업체 자격 증명이 소스 제어 (source control)에 포함되지 않도록 유지합니다.
배포에는 또한 하나의 정규 호스트 (canonical host)가 있습니다. www 호스트네임과 Pages 프리뷰 호스트네임에 대한 요청은 에이펙스 도메인 (apex domain)으로 리다이렉트됩니다. 이는 다음과 같은 몇 가지 미묘한 문제들을 한 번에 해결했습니다:
- OAuth 콜백 (callbacks)이 일관되게 하나의 오리진 (origin)을 사용합니다.
- 세션 쿠키 (session cookies)가 예측 가능하게 동작합니다.
- 검색 엔진이 각 페이지의 하나의 정규 버전을 봅니다.
- 분석 (Analytics) 데이터가 호스트네임에 따라 동일한 제품을 파편화하지 않습니다.
저는 생성된 사이트맵 (sitemap), robots.txt, 정규 메타데이터 (canonical metadata), 구조화된 데이터 (structured data), 파비콘 (favicon), 그리고 제품 사진 및 투명 PNG 생성과 같은 실제 사용 사례를 위한 집중된 랜딩 페이지를 추가했습니다. SEO가 애플리케이션 아키텍처를 변경하지는 않았지만, 정규 호스트 (canonical-host) 결정은 확실히 아키텍처를 변화시켰습니다.
다음에 다시 한다면 더 일찍 할 일들
만약 제가 또 다른 API 기반의 SaaS (Software as a Service)를 구축한다면, 인터페이스를 다듬기 전에 다음과 같은 결정들을 내릴 것입니다:
- 개인정보 보호 경계 (privacy boundary)를 정확하게 정의할 것. 사용자 콘텐츠를 수신하는 모든 시스템을 나열하고, 처리 (processing)와 영속성 (persistence)을 구분하십시오.
- 사용량 (usage)을 상태 머신 (state machine)으로 설계할 것. 단일 카운터가 아니라 예약됨 (reserved), 완료됨 (completed), 해제됨 (released)의 관점에서 생각하십시오.
- 정규 오리진 (canonical origin)을 즉시 결정할 것. OAuth, 쿠키 (cookies), 결제 리턴 URL (payment return URLs), 메타데이터 (metadata), 그리고 리다이렉트 (redirects)에 이를 사용하십시오.
- 첫날부터 웹훅 (webhooks)을 멱등적 (idempotent)으로 만들 것. 실제 결제가 이루어진 후에 이벤트 중복 제거 (event deduplication) 기능을 사후에 적용하는 것은 불필요하게 스트레스가 큰 일입니다.
- 플랜 (plans) 정보를 서버에 유지할 것. 가격, 통화, 크레딧 (credits), 그리고 만료 규칙은 브라우저로부터 절대 신뢰해서는 안 됩니다.
- 제3자 서비스의 실패를 정규화할 것. 사용자에게는 가공되지 않은 제공자 응답이 아니라, 실행 가능한 제품 오류 (product errors)가 필요합니다.
- 실패 경로 (failure path)를 테스트할 것. 타임아웃 (timeouts), 중복 콜백 (duplicate callbacks), 유효하지 않은 서명 (invalid signatures), 그리고 할당량 소진 (exhausted quotas)은 정상적인 운영 상태입니다.
맺음말
배경 제거 모델은 이 제품에서 가장 어려운 부분이 아니었습니다. 왜냐하면 저는 의도적으로 특화된 API를 사용했기 때문입니다. 흥미로운 엔지니어링 작업은 그 주변의 모든 것이었습니다. 즉, API 경계를 보호하고, 사용량을 정확하게 계산하며, 에지 (edge)에서 신원 (identity)을 처리하고, 결제 알림을 신뢰할 수 있는 권한 (entitlements)으로 전환하는 작업이었습니다.
이는 또한 고무적인 부분입니다. 작은 제품에는 거대한 인프라 스택이 필요하지는 않지만, 명확한 신뢰 경계 (trust boundaries)와 세심한 상태 전이 (state transitions)는 반드시 필요합니다.
완성된 구현체는 CutoutKit에서 확인하실 수 있습니다. 만약 여러분이 유사한 API 기반 도구를 구축하고 있다면, 어떤 부분이 가장 큰 어려움을 주었는지 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기