Daeseon Yoo
Back to project
·Tech retro·11 min

인프라는 두 층이다 — Control Plane은 IaC, Data Plane은 API

딸깍 PaaS를 '테라폼 같은 IaC로 정교하게' 만들 수 있냐는 질문에서 출발. 7개 PaaS(Vercel·Netlify·Railway·Render·Fly·Supabase·Cloudflare) 실제 방식과 1차 출처를 확인해보니 답은 '두 층으로 나눠라'였다. 우리 코드가 이미 가진 것과 빠진 것까지 정리한 학습노트.

질문

딸깍 PaaS는 지금 유저 앱을 배포할 때 Cloudflare REST API를 직접 호출한다. 문득 든 의심: "이거 좀 손으로 짠 느낌인데, 테라폼 같은 IaC로 하면 더 정교한 거 아냐?"

직감으로 답하기 싫어서 실제 PaaS 7곳이 어떻게 하는지 1차 출처로 확인했다. 결론이 생각보다 깔끔해서 학습노트로 박제한다.

핵심 멘탈모델 — 인프라는 한 종류가 아니다

같은 "인프라"라도 성격이 정반대인 두 층이 있고, 맞는 도구가 층마다 뒤집힌다.

Control Plane (내 것)Data Plane (유저별)
누가·언제 만드나나, 가끔 (셋업·설정 변경)모든 유저, 클릭할 때마다
개수몇 개 안 되고 오래 삶수천 개, 가입자 수 따라 증가
트리거운영자가 커밋실시간 유저 액션
성격선언적·정적·상태보존명령적·이벤트기반·실시간
맞는 도구Terraform / IaCAPI 직접 호출

도구가 뒤집히는 이유는 비용 구조가 뒤집히기 때문이다. IaC의 강점(리뷰된 단일 desired-state, 잠긴 상태파일, plan→수렴 사이클)은 사람이 가끔 기반 인프라를 바꿀 땐 자산이지만, 똑같은 기계가 낯선 사람들의 버튼 클릭에 시간당 수천 번 얻어맞으면 그대로 부채(잠금 경합·지연·반쯤 적용된 깨진 상태)가 된다.

업계 표준 프레이밍은 AWS의 "control plane vs application plane" 백서고, Omnistrate는 아예 한 문장으로 못 박는다: "Terraform은 선언적·정적이다. 실시간 유저 액션으로 테넌트를 동적으로 띄우려면 API 기반 프로비저닝 모델의 control plane이 필요하다."

숫자와 사실 — 7개 PaaS 전부 유저 배포는 API다

per-tenant 런타임 프로비저닝을 terraform apply로 하는 곳은 한 곳도 없었다. 전부 프로그래밍 API다:

특히 Cloudflare는 두 가지를 못 박는다:

  1. 유저 배포 경로 = API 요청 (테라폼 언급 자체가 없음)
  2. "고객마다 네임스페이스 만들지 마라" — 전 고객이 하나의 dispatch 네임스페이스를 공유

왜 유저 배포에 테라폼이 "틀린" 도구인가 (그냥 '불필요'가 아니라)

  1. v5 프로바이더는 그 동작을 아예 못 한다. Cloudflare TF 프로바이더 v5에는 dispatch 네임스페이스에 유저 스크립트를 올리는 인자(dispatch_namespace)가 삭제됐다(v4엔 있었음, v4.27에서 이슈 #2426 해결로 추가됐던 게 v5 재작성 때 누락). 네임스페이스 컨테이너 자체(cloudflare_workers_for_platforms_dispatch_namespace)는 만들 수 있지만 그 안의 테넌트 스크립트는 못 넣는다.
  2. 상태 잠금이 모든 배포를 직렬화한다. 테라폼은 상태를 쓰는 모든 작업에 자동으로 락을 건다 → 공유 상태 하나면 모든 유저 배포가 뮤텍스 하나 뒤에 줄 선다.
  3. 테넌트별 상태파일은 폭발한다. 락을 피하려 고객마다 상태를 나누면 → 수천 개의 상태 백엔드를 저장·보안·잠금·백업·GC 해야 함. 가입할 때마다 프로그래밍으로 상태를 찍어내는 건 테라폼이 설계된 용도가 아니다.
  4. apply는 API 호출이 아니라 plan+수렴 사이클이다. 그리고 실패 시 "테라폼은 인프라를 깨진 상태로 남긴다" — 비개발자가 방금 "배포" 누른 상황에 용납 불가.
  5. Cloudflare가 이미 상태파일이 주려던 보장을 준다. WfP의 첫 User Worker 업로드는 이제 동기식 — 200 OK면 스크립트가 완전히 프로비저닝돼 트래픽 받을 준비 완료. 상태파일 없이도 "클릭 = 프로비저닝 완료"가 성립.

그럼 IaC는 어디에 쓰나 (Control Plane)

우리가 가끔 만드는 기반 객체엔 테라폼이 딱이다 — dispatch 네임스페이스 1개(고객마다가 아니라 전체 1개니 진짜 control-plane), 라우팅하는 dispatcher Worker, DNS/zone, 플랫폼 자체를 받치는 공유 D1/KV/R2, 계정 설정. 여기서 얻는 게 IaC의 진짜 값: 리뷰(대시보드 오조작이 아니라 diff), 재현(같은 코드로 스테이징/2번째 계정 세움), 롤백(커밋 되돌리고 재적용).

예외는? — Pulumi Automation API

embedded IaC(Pulumi Automation API: up/destroy를 라이브러리 호출로, 테넌트별 stack 격리)는 진짜 예외다. 단 테넌트마다 인프라가 다중 리소스 그래프일 때(전용 DB 클러스터·VPC·K8s). CockroachDB·Snowflake·Lemonade가 이렇게 쓴다. 하지만 우리 테넌트 단위는 공유 네임스페이스에 스크립트 하나 올리는 것 — 동기식으로 멱등하게 프로비저닝되는 API 객체 하나다. 여기에 Automation API를 쓰면 런타임 CLI 의존성 + 테넌트별 상태 백엔드 + 지연을 사서 거의 안 쓰는 보장을 얻는 꼴. 변곡점은 테넌트 수가 아니라 테넌트당 그래프 복잡도 + teardown/drift 위험. (CDKTF는 고려 말 것 — HashiCorp가 2025-12-10 아카이브함.)

우리 코드는 지금 어디까지? — 가진 것 vs 빠진 것

코드를 file:line으로 감사한 결과. 어려운 절반(상태머신+멱등성)은 이미 있고, 나머지 절반(control loop: 인벤토리→reconcile→teardown)이 없다.

IaC 원칙우리 코드할 일
상태머신✅ 있음·강제 (deployStore.ts 전이표 + sqliteStores.ts compare-and-swap)유지
멱등성✅ 2겹 (createD1 재사용, PUT 업서트, idempotency_key 원자 dedup)프로바이더 단계까지 확장
리소스 인벤토리⚠️ 부분 — 배포 후 D1 uuid를 버린다(cloudflareExecutor.tscf_ref만 기록)만든 CF 리소스 id 전부 저장
drift 감지❌ 프리미티브(getScriptStatus)는 있는데 호출하는 곳이 없음주기적 sweep에 연결
teardown전무 — DELETE 라우트 0, rolled_back은 선언만 되고 아무도 전이 안 함삭제 경로 추가 (안 하면 리소스 무한 누수)
반쯤 실패 복구failed가 종점, 보상 없음 → deployScript 실패 시 D1 고아 발생재진입 또는 보상 로직

가장 급한 건 teardown + 인벤토리: 지금은 실패/삭제된 앱마다 Worker와 D1이 조용히 새고, 뭘 지워야 하는지 기록조차 없다. 비개발자 클릭 하나하나가 쌓이는 비용·정합성 버그.

결론 한 줄

"IaC로 전부"가 정교한 게 아니다. 층마다 맞는 도구가 정교한 거다. Control plane엔 테라폼(리뷰·재현·롤백), data plane엔 Cloudflare API 직접(그게 CF 문서·업계 표준이고 테라폼은 여기선 정교함). 그리고 IaC의 도구가 아니라 원칙(인벤토리·reconcile·teardown·멱등성)을 우리 손코딩 REST 클라이언트 위에 얹는 게 진짜 "제대로."

자기점검 질문 (나중의 나에게)

  1. 어떤 리소스가 control plane이고 어떤 게 data plane인지 5초 안에 구분할 기준은? (누가·얼마나 자주 만드나)
  2. 유저 배포마다 terraform apply가 왜 직렬화되나? (상태 잠금)
  3. "테넌트마다 상태파일"의 대가는? 왜 테라폼이 그걸 못 견디나?
  4. Cloudflare가 상태파일 없이도 "클릭=완료"를 주는 근거 한 줄은? (동기식 200 OK)
  5. Pulumi Automation API를 꺼낼 변곡점은 테넌트 '수'인가 '무엇'인가?
  6. 우리 코드가 이미 가진 IaC 원칙 2개와, 없어서 리소스가 새는 원칙 1개는?