TangYi Studio

탕이 인사이트

엣지 위의 파티: 한 사람과 하나의 AI로 파티 게임을 Cloudflare Workers에 올리기

林政賢 ·

이것은 "AI가 hello world를 써주었다" 같은 이야기가 아닙니다. 이미 출시된 앱의 이야기입니다. 카드 1,428 장, 22 개 페이지, 17 개 데이터 테이블, 실시간 멀티플레이, 인앱 구독, App Store 심사 통과. 이 글에서 다루는 것은 이 조합이 실제로 통하는가, 그리고 아무도 미리 알려주지 않는 함정들입니다.

먼저 규모를 분명히 해두겠습니다. 그렇지 않으면 뒤의 이야기에 무게가 실리지 않습니다.

퍼플 티시 나이트X · 현황

1,428장의 질문 카드. 4 개 시리즈, 3 단계 난이도, 7 개 한정 덱
39장의 엔딩 카드. 6 단계 희귀도와 자이로 연동 3D 효과 포함
22개 프런트엔드 페이지, 그중 16 개는 필요할 때 불러오기
17개 업무 테이블과 15 개 마이그레이션
2~10명이 동시에 들어가는 실시간 룸. 채팅, 채점, 매칭, 신고와 차단 포함

전부 Cloudflare Workers 위에서 돌아갑니다. Worker 하나가 API이자 정적 사이트이고, 데이터는 D1, 이미지는 R2, 실시간 메시지는 Ably, 로그인은 better-auth, 구독은 RevenueCat. iOS 앱은 같은 웹사이트를 Capacitor 껍데기로 감싼 것 — 즉 화면도 규칙도 카드도 데이터베이스도, 바꾸는 데 새 빌드가 필요 없습니다.

개발 과정은 거의 전부 저와 Claude의 대화였습니다. 제가 무엇을 원하는지 설명하고, 실제 기기에서 보고, 무엇이 잘못됐는지 짚습니다. 상대는 코드를 읽고, 원인을 찾고, 고치고, 배포하고, 검증합니다. 이 글은 그것이 마법이라고 설득하려는 것이 아닙니다. 제가 쓰고 싶은 것은 어느 부분이 정말로 많은 시간을 아껴주었고, 어느 부분을 자신 있게 틀리는가입니다.

왜 Cloudflare인가

싸서가 아닙니다(실제로 싸긴 합니다). Worker 하나가 전부이기 때문입니다.

전통적인 방식은 프런트 호스팅, API 서버, 데이터베이스, CDN, 파일 스토리지 다섯 군데로 나뉘고, 각각 설정하고, 각각 과금되고, 각각 자기만의 배포 절차를 갖습니다. Workers에서는 그것들이 같은 wrangler.json 안의 바인딩 몇 줄입니다.

"d1_databases":  [{ "binding": "DB",     "database_name": "ppnx-db" }],
"r2_buckets":    [{ "binding": "MEDIA",  "bucket_name": "ppnx-media" }],
"assets":        { "directory": "./dist/client", "binding": "ASSETS" }

그다음 npm run deploy 한 번으로 전부 올라갑니다. "한 사람이 제품을 통째로 만든다"에게 이것은 아끼는 비용보다 훨씬 큰 의미가 있습니다 — 유지해야 할 머릿속 모델이 하나뿐이기 때문입니다. 그리고 협업 상대가 AI일 때 이 점은 더 결정적입니다. 시스템 전체를 한 번에 읽을 수 있어야 상대가 당신이 무엇을 만드는지 진짜로 이해할 수 있습니다. 그렇지 않으면 나머지 네 개 서비스가 지금 어떤 상태인지 매번 추측하게 됩니다.

왜 Claude인가

"코드를 쓸 줄 알아서"가 아닙니다. 그 문턱은 이미 오래전에 넘었습니다. 진짜 차이를 만드는 것은 기억으로 답하지 않고 확인하러 간다는 점입니다.

이번 주에 실제로 있었던 예를 하나 들겠습니다. 사용자 제보: "방 안이 전부 느리고, 버튼을 눌러도 반응이 없어요."

기억으로 답하면 "네트워크가 느리니 캐시를 넣죠"입니다. 실제로 한 일은, 먼저 대만에서 Worker까지 왕복 시간을 재는 것이었습니다(0.43~0.70 초, 확실히 느림. 대만 회선이 산호세로 향하는데 데이터베이스는 아시아태평양에 있기 때문). 최적화를 넣었고 — 그리고 사용자는 여전히 느리다고 했습니다.

네트워크가 느리다고 버튼이 반응하지 않게 되지는 않는다.

이 한마디가 방향을 통째로 뒤집었습니다. 두 번째로 잰 것은 완전히 다른 대상이었습니다 — 페이지가 아무 조작 없이 놓여 있을 때의 DOM 변경 횟수.

같은 화면, 조작 없음, 10 초 동안의 DOM 변경

78 회고치기 전 — 아무것도 안 하는데 초당 여덟 번씩 다시 그리고 있었다
0 회고친 뒤

원인은 채팅 말풍선이 만료됐는지 판단하는 1 초짜리 타이머가 조건 없이 계속 돌고 있었던 것이고, 그것이 한 번 움직일 때마다 룸 컴포넌트 전체가 다시 렌더링됐습니다. 그 시계는 말풍선이 떠 있는 6 초 동안만 필요하고, 나머지 시간에는 전혀 필요 없습니다.

이 버그는 아주 오래 있었습니다. 찾아낸 것은 더 똑똑한 추측이 아니라 다른 것을 재봤기 때문입니다. 그리고 "고치기 전에 잰다"는 규율은 AI가 사람보다 훨씬 참을성 있게 지킵니다 — 답을 이미 안다고 느낀다고 해서 단계를 건너뛰지 않습니다.

아무도 미리 알려주지 않는 Cloudflare 함정 네 가지

이 절이 이 글의 핵심입니다. 아래는 전부 실제로 저를 최소 반나절씩 막았고, 첫눈에는 완전히 다른 원인처럼 보입니다.

01없는 JS 파일은 404가 아니라 200을 돌려준다

증상: 배포한 뒤, 이미 앱을 열어둔 사용자가 어떤 페이지로 들어가면 깨집니다. 오류 메시지로는 원인을 전혀 알 수 없습니다.

/assets/ 아래 파일명에는 내용 해시가 들어가고 배포할 때마다 바뀝니다. 오래된 탭은 옛 파일명을 요청하는데, not_found_handling: "single-page-application"찾을 수 없는 모든 경로에 홈페이지 HTML을 돌려줍니다.js까지 포함해서. 그래서 브라우저는 "HTTP 200 + text/html"을 받고 그것을 JS 모듈로 해석하려다 MIME 오류를 냅니다.

해법: 동적 로딩이 실패하면 페이지를 한 번만 새로고침합니다(새로고침하면 새 HTML과 새 파일명이 옵니다). 무한 루프를 피하려고 sessionStorage에 기록해 둡니다. 이건 SPA + 엣지 정적 호스팅 전반의 문제이지 Cloudflare 고유의 것은 아니지만, 이 설정 때문에 "명백한 404"가 "괴상한 MIME 오류"로 둔갑합니다.

02존의 캐시 설정이 Worker가 보낸 헤더를 덮어쓴다

증상: Worker에서 Cache-Control을 설정했는데, 내용을 바꿔도 반영되기까지 몇 시간이 걸립니다.

Cloudflare 대시보드의 존 레벨 Browser Cache TTL(기본 4 시간)이 Worker가 돌려주는 Cache-Control보다 우선합니다. 대칭되는 또 하나의 오해: Worker 응답은 기본적으로 엣지에 캐시되지 않습니다. 캐시해 주는 줄 알았지만 아닙니다 — caches.default를 직접 써야 합니다.

해법: 존 TTL을 우회하려면 URL에 바뀌는 파라미터를 붙입니다(분 단위 ?v= 등). 엣지 캐시를 원하면 caches.default를 명시적으로 씁니다. 둘 다 저절로 일어나지 않습니다.

03OAuth 콜백이 정적 자원 계층에 가로채인다

증상: Google 로그인 콜백은 404, Apple은 405. 그런데 같은 주소를 curl로 때리면 완전히 정상입니다.

정적 자원 계층이 "내비게이션 요청"을 가로채고, SPA 폴백 때문에 콜백이 Worker까지 도달하지 못합니다. curl로 재현되지 않는 이유는 그것이 내비게이션 요청이 아니기 때문입니다 — Sec-Fetch-Mode: navigate를 붙여야 재현됩니다.

해법: 해당 경로들을 run_worker_first에 넣습니다. 그리고 Worker를 하위 경로에 마운트한다면 html_handling: "none"을 꼭 설정하세요. 안 그러면 방문자가 307로 메인 사이트 홈으로 튕깁니다.

04wrangler 로그인 권한은 생각보다 좁다

증상: 어떤 wrangler 명령은 늘 Unauthorized [code: 2036]을 돌려줍니다. 명령을 아무리 고쳐 써도 소용없습니다.

wrangler login으로 받은 OAuth 토큰에는 DNS 쓰기 권한도, Email 권한도 없습니다. 그래서 "Pages 커스텀 서브도메인용 CNAME 만들기", "Email Sending 활성화" 같은 작업은 반드시 실패하고, 명령이 맞고 틀리고와는 무관합니다.

해법: 대시보드에서 해당 권한을 가진 API 토큰을 새로 만들거나, 우회하거나. 저는 DNS를 Worker 경로 마운트로 바꾸고, 메일 발송은 이미 쓰던 외부 서비스로 넘겼습니다. 명령을 디버깅하는 데 시간을 쓰기 전에 권한 문제인지부터 확인하세요. 이 항목이 가장 많은 시간을 아껴줍니다.

엣지의 지리적 현실

Workers는 전 세계 엣지 노드에서 돌아가니 어디서나 빠를 것처럼 들립니다. 하지만 당신의 데이터베이스는 한 곳에만 있습니다.

대만의 머신에서 저희 API까지 실제로 재보면 왕복 0.43 ~ 0.70 초입니다. 이유는 Cloudflare가 대만 회선을 산호세(colo=SJC)로 보내는데 D1은 아시아태평양에 있기 때문입니다. 요청은 먼저 태평양을 건너 Worker에 닿고, Worker는 다시 건너와 데이터베이스를 조회합니다.

진단은 아주 간단합니다. wrangler tailcpuTimewallTime을 함께 보여줍니다. CPU 6 밀리초에 벽시계 470 밀리초라면, 느린 것이 당신 코드가 아니라는 증거입니다.

이것을 알고 나면 설계 원칙은 두 가지만 남습니다.

다만 낙관적 업데이트가 당신을 뭅니다

이번 주에 제가 가장 좋아하는 버그입니다. 하나를 고치자 곧바로 다른 하나를 만들어낸 교과서적인 사례이기 때문입니다.

낙관적 업데이트를 올린 뒤, 사용자들이 화면이 "이리저리 튄다"고 제보했습니다. 카드를 뽑으면 덱 선택으로 한 번 돌아갔다가 카드로 넘어가고, 답을 보내면 화면이 뒤로 갔다가 다시 앞으로 갑니다.

원인은 폴링이 독립적으로 돌고 있다는 것입니다. 당신의 쓰기 이전에 보내진 요청이 오래된 데이터를 들고 돌아오고, 그것을 그대로 적용하면 방금 만든 화면이 뒤로 밀립니다. 그리고 다음 폴링에서 다시 앞으로 갑니다. 지연을 고치고 있다고 생각하지만, 실제로는 깜빡임을 만들고 있는 것입니다.

해법에는 타임스탬프가 필요 없고 시계 어긋남도 두렵지 않습니다. 단조 증가하는 카운터로 어느 쪽이 더 새것인지 판단하면 됩니다.

function mergeRoom(prev, incoming){
  const pT = prev.turn_count, iT = incoming.turn_count;
  const pC = prev.current_turn, iC = incoming.current_turn;
  if (iC > pC || iT > pT) return incoming;   // 서버가 더 새것 → 통째로 수용
  if (iC < pC || iT < pT) return prev;       // 폴링이 뒤처짐 → 통째로 버림
  // 같은 턴: 서버가 우리 동작을 아직 못 받음 → 그 필드만 남긴다
  if (prev.last_pick && !incoming.last_pick) return { ...incoming,
        last_pick: prev.last_pick, last_by: prev.last_by };
  return incoming;
}

상태 안에 "앞으로만 가고 뒤로 가지 않는 숫자"가 하나만 있으면 — 턴 수, 버전 번호, 시퀀스 번호 무엇이든 — 이 문제에는 아주 값싼 해법이 있습니다. 조정 전략이 없는 낙관적 업데이트는 지연을 깜빡임과 맞바꾸는 것일 뿐입니다.

Claude는 어디서 틀리는가

이 절은 이런 방식으로 일해보려는 사람을 위한 것입니다. 만능이 아니고, 틀리는 방식에는 규칙이 있습니다.

하나, 자신 있게 "고쳤습니다"라고 말한다

가장 흔한 실패 형태: 코드를 고치고 배포한 다음, 자기 브라우저의 오래된 캐시나 로컬 빌드 산출물을 보고 "수정 완료"라고 보고하는 것. 이 프로젝트에서도 한 번이 아니었습니다.

유효한 규율은 하나뿐입니다. 운영에서 실제로 서빙되는 파일을 검증하는 것. 배포된 청크를 curl로 받아 핵심 문자열을 대조합니다. 그리고 grep이 정말 걸렸는지도 확인하세요 — 압축하면 공백이 사라지므로 공백이 들어간 검색 문자열은 빗나가고, "안 올라갔다"처럼 보이는 가짜 경보가 생깁니다.

둘, 자기 선택에 그럴듯한 이유를 지어낸다

"왜 이렇게 했나"고 물으면 거의 반드시 전문적으로 들리는 설명을 내놓습니다 — 진짜 이유가 "앞 단계가 마침 그렇게 쓰여 있어서"일 때조차. 이것은 README나 기술 문서를 쓸 때 특히 위험합니다. 그 문장들은 다른 사람에게 설계 결정으로 읽히기 때문입니다.

제 방식은 중요한 것은 공개 전에 독립적인 적대적 감사를 한 번 돌리는 것입니다. 깨끗한 대화를 새로 열고, 코드만 주고, 흠을 잡아달라고 하고, 원래 설명은 주지 않습니다.

셋, "아직 아니다"라고 말해줄 사람이 필요하다

앞의 "전부 느리다" 사례에서 첫 번째 최적화는 옳았고, 측정도 진짜였고, 방향만 틀렸습니다. 그것을 제자리로 돌린 것은 더 좋은 프롬프트가 아니라 "여전히 느리다"라는 한마디였습니다.

아마 이 글 전체에서 가장 실용적인 문장일 겁니다. 당신의 가치는 그 코드를 쓸 수 있느냐가 아니라, 아직 끝나지 않았음을 안다는 데 있습니다. 실제 기기에서 시험하고, 구체적인 증상을 말하고, "이제 될 겁니다"를 받아들이지 않는 것 — 이것을 대신할 수 있는 것은 아직 없습니다.


그래서, 이건 복제할 수 있나

할 수 있습니다. 다만 주제를 골라야 합니다.

이 조합이 가장 강한 곳은 한 사람이 전부를 책임지는 프로젝트입니다. 프런트·백엔드·운영의 구분이 없고, 팀 간 소통 비용이 없고, 한 줄 고쳐서 공개까지 명령 하나. Workers는 인프라를 한 사람이 감당할 크기로 압축하고, Claude는 "삼만 줄을 읽고 한 줄을 제대로 고치는" 비용을 낮춥니다. 두 가지가 동시에 성립해야 의미가 있고, 하나만으로는 부족합니다.

반대로 시스템이 이미 다섯 개 클라우드, 세 개 팀, 두 개 배포 절차에 흩어져 있다면 병목은 코드를 쓰는 것이 아니므로 이 조합은 큰 도움이 되지 않습니다.

"AI가 엔지니어를 대체할까"에 대해서는, 이 몇 주의 경험으로 보면 대체되는 것은 문서를 찾고, 상용구를 쓰고, 누가 무엇을 망가뜨렸는지 추적하는 일들입니다. 사람에게 남는 것은 무엇을 할 가치가 있는지 판단하는 일, 그리고 실제 기기를 보며 "여기가 잘못됐다"고 말하는 일입니다. 뒤쪽의 무게는 가벼워지기는커녕 무거워지기만 했습니다.

이 글에 대하여: 본문의 모든 숫자는 실측이며 추정이 아닙니다. 0.43~0.70 초는 대만 머신에서 운영 API로 실제 측정한 값이고, 78 → 0은 같은 화면을 10 초 방치했을 때의 DOM 변경 횟수이며, 카드와 테이블 수는 데이터베이스를 직접 조회한 값입니다. Cloudflare 함정 네 가지에는 원인과 해법을 모두 붙였습니다. "지뢰가 있다"고만 말하면 읽는 사람에게 쓸모가 없기 때문입니다.

퍼플 티시 나이트X는 TangYi Studio의 16+ 파티 퀴즈 카드 앱입니다. 스튜디오의 오픈소스 프로젝트는 github.com/tangyistudio에 있습니다.

쉬운 버전

기술 이야기 없이 요점만 보고 싶다면

같은 프로젝트를 비엔지니어를 위해 쓴 글도 있습니다. 다루는 축이 다릅니다 — AI가 이만큼 강해진 뒤 사람에게 무엇이 남는가. 그 글의 중심은 방향을 통째로 뒤집은 한마디, "여전히 느리다"입니다.

쉬운 버전 읽기 →

새 글

AI 램프 두 개, 어느 쪽을 문지를까?

Claude Fable 5.1과 GPT-6 Astra가 이틀 사이에 같은 가격으로 나왔다. 점수 비교가 아니라 질문 하나 — vibe coding을 시작하려면 누구에게 돈을 내야 하나.

읽기: AI 램프 대전 →

저자:林政賢(감독 · Gen AI 크리에이터 겸 엔지니어 · 탕이 스튜디오 창업자)