본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 06. 03. 09:24

275개 커밋 중 54%가 버그 수정이었다——AI로 스마트폰 앱을 생성하는 서비스를 개인 개발하며 밟은 모든 지뢰

요약

AI 기반 앱 UI 생성 서비스인 'SparkAI'를 개발하며 겪은 275개 커밋 중 54%에 달하는 버그 수정 사례를 공유합니다. Next.js, Supabase, Anthropic Claude 스택을 사용하며 마주한 인증, OAuth, 서버 오류 등의 기술적 문제와 해결 방법을 다룹니다.

핵심 포인트

  • Next.js 16 App Router와 Supabase 기반의 기술 스택 활용
  • Supabase 인증 로딩 및 Google OAuth 루프 문제 해결
  • Vercel 504 오류 및 SSR 환경에서의 window 참조 이슈 대응
  • 보안을 위한 인증 없는 API 엔드포인트 방지 및 RLS 적용

인증 행(Hang)·OAuth 루프·504·인증 없는 API——설계부터 보안 감사까지, 근본 원인과 수정 코드를 전부 작성합니다.

만든 것: 일본어로 말을 걸면 AI가 스마트폰 앱 화면을 자동 생성하는 서비스 「SparkAI」 -
규모: 개발 2개월·275 커밋·그중 54%가 버그 수정·배포 횟수는 셀 수 없음 -
스택: Next.js 16 App Router / Supabase / Anthropic Claude / Stripe / Vercel -
밟은 지뢰: Supabase 인증의 영구 로딩·Google OAuth 루프·Vercel 504·SSR의 window 참조·인증 없는 API 엔드포인트 -
이 글을 통해 얻을 수 있는 것: 각 버그의 근본 원인과 완전한 수정 코드. 동일한 스택으로 개발하는 사람들의 지뢰 회피 맵으로 사용해 주세요

👤 개인 개발의 시행착오나 SparkAI의 진행 상황을 X에서 실황 중 → @naokaihatu

개발 2개월. 총 커밋 275개. 그중 147개(54%)가 버그 수정. 배포 횟수는 세는 것을 포기했습니다.

「동작하는 것을 빠르게 내놓는다」를 우선하여 출시했더니, 사용자로부터 몇 건의 버그 보고가 도착했고, 정신을 차려보니 커밋 이력의 절반 이상이 fix로 채워져 있었습니다. 이 글은 그 모든 기록입니다.

만든 것은 SparkAI(https://sparkai-app.jp)——「일본어로 말을 걸면 AI가 스마트폰 앱 화면을 자동 생성하는」 Web 서비스입니다.

「Figma에서 화면을 만들고, 코드로 옮기는 왕복 과정이 너무 번거롭다」라는 저 자신의 과제로부터 만들기 시작했습니다. 채팅으로 지시하면 AI가 여러 화면의 앱 UI를 생성하고, React Native (Expo Snack) 또는 Web 코드로 내보냅니다.

スクリーンショット 2026-06-03 0.29.37.png

기능을 한마디로 말하면:

조작결과
「헬스케어 앱 만들어줘」라고 채팅여러 화면의 UI가 한 번에 생성
...
┌─────────────────────────────────────────────────────┐
│ 브라우저 (Safari / Chrome) │
│ Next.js 16 App Router │
...
카테고리채택 기술선정 이유
프레임워크Next.js 16.2.4 (App Router)SSR/RSC로 서버 측 인증을 작성하기 쉬움
인증·DBSupabase + @supabase/ssr v0.10.2RLS로 사용자 데이터를 선언적으로 분리할 수 있음
AIClaude Sonnet 4.6 / Haiku 4.5일본어 UI 생성 품질이 높음
결제StripeWebhook + Checkout Session의 신뢰성
호스팅VercelNext.js와의 통합이 우수함
스타일Tailwind CSS + CSS Variables테마 컬러를 동적으로 전환하기 위해 병용

AI 생성 도구는 이미 무수히 많습니다. 그중에서 계속 사용하게 만들려면, 무언가 하나 「이것만큼은 지지 않는다」라는 축이 필요하다고 생각했습니다.

SparkAI가 선택한 축은 **「초보자가 헤매지 않고 사용할 수 있는 것」**입니다.

AI 도구에서 흔히 발생하는 「무엇을 입력해야 할지 모르겠다」, 「생성된 것을 어떻게 사용해야 할지 모르겠다」를 최대한 없애기 위해 다음과 같이 설계했습니다:

  • 입력창에는 플레이스홀더(Placeholder)로서 실제 입력 예시를 표시한다 (「헬스케어 앱을 만들어줘. 걸음 수와 수면을 기록할 수 있도록」)
  • 생성 후에는 즉시 프리뷰를 볼 수 있다. 코드를 이해하지 않아도 UI 확인이 가능하다
  • 「다음 단계」 배너로 「지금 무엇을 하는 화면인지」를 항상 안내한다
  • 에러가 발생하면 「재생성하기」 버튼만 보여준다. 원인에 대한 설명은 하지 않는다

「무엇을 할 수 있는지 알 수 있다 → 시도할 수 있다 → 동작했다」 이 3단계 과정을 10초 이내에 체험하는 것을 목표로 삼았습니다.

또 하나 공들인 점은 프로젝트로서 저장·지속할 수 있다는 것입니다. 많은 생성 도구는 「한 번 만들고 끝」입니다. SparkAI는 만든 UI를 저장하고, 이어서 수정할 수 있으며, 최종적으로 공유 및 갤러리 공개까지 이어질 수 있도록 설계했습니다. 「만든 것」이 아니라 「키워나가는 것」으로 사용해 주길 바랐기 때문입니다.

——설계 면에서는 철저히 신경을 썼다고 생각했습니다. 하지만 실제 서비스를 출시한 순간부터, 그 이상은 현실의 버그들에 의해 차례차례 얻맞게 됩니다. 지금부터는 실제로 밟았던 지뢰들을 하나씩 해부해 보겠습니다.

출시 다음 날. 사용자로부터 이런 메시지가 왔습니다.

「계속 로딩 중이라 화면이 나오지 않아요」

브라우저 콘솔을 확인하니:

[SparkAI] Auth initialization timed out after 5s — showing recovery UI

Supabase에 curl로 요청을 보내면 150ms 만에 응답이 옵니다. 하지만 브라우저에서는 5초가 지나도 인증이 끝나지 않습니다. 도대체 무슨 일이 일어나고 있는 걸까요.

@supabase/ssrcreateBrowserClient는 인스턴스 생성과 동시에 initialize()비동기적으로 자동 실행합니다. getSession()onAuthStateChange 모두 이 initializePromise가 완료될 때까지 대기합니다.

createBrowserClient()
└── initialize()를 비동기로 시작 (이곳이 문제의 기점)
└── _recoverAndRefresh()
...

범인은 '과거에 로그인했던 사용자의 만료된 토큰'이었습니다. initialize()가 토큰 리프레시(Refresh)를 시도하면, Safari 특정 환경에서 이 fetch가 Abort(중단)도 Reject(거부)도 하지 않고 그저 멈춰버립니다. getSession()은 그 완료를 영원히 기다리게 됩니다.

Layer 1 — fetch 자체에 4초의 타임아웃(Timeout) 설정

// src/lib/supabase/client.ts
function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
...

Layer 2 — getSession() 전체를 Promise.race로 타임아웃 처리

Layer 1만으로는 불충분했습니다. AbortError가 Supabase 내부의 try-catch에 흡수되어, getSession()의 Promise가 영원히 pending 상태로 남는 케이스가 존재했기 때문입니다.

getSession()이 3초 내에 해결되지 않으면 stale(오래된) 토큰을 삭제하고 null로 해결한다」라는 래퍼(Wrapper)를 만들었습니다:

// src/context/AuthContext.tsx
// sb-* 접두사가 붙은 Cookie와 localStorage를 모두 삭제하는 함수
function clearSupabaseStorage() {
...

Layer 3 — 5초 백업으로 「회복 UI」 표시

onAuthStateChange도 멈춰버린 경우를 대비한 최후의 수단입니다. 이때 절대로 검은 화면을 보여주지 않는다는 것을 가장 중요한 규칙으로 삼았습니다:

t=0s 페이지 로딩 시작
t=0s getSession() 실행 + 3초 타이머 시작
↓
...

수정 ① 직후에 또 다른 보고가 들어왔습니다.

「Google로 로그인을 누르면, 잠시 로딩되다가 다시 로그인 화면으로 돌아옵니다」

재현해 보니 확실히 루프(Loop)가 발생하고 있었습니다. 원인은 두 가지가 겹쳐 있었습니다.

Next.js App Router에는 Cookie를 조작하는 방법이 두 가지가 있습니다:

방법작성되는 위치
cookies() from next/headersNext.js 내부의 Cookie 스토어
response.cookies.set()응답 객체(Response Object)에 직접

이 두 가지를 혼용하면, NextResponse.redirect()에 Cookie가 포함되지 않습니다.

// ❌ 구형 코드 (작동하지 않음)
const supabase = await createClient(); // 내부에서 cookies()를 사용
await supabase.auth.exchangeCodeForSession(code); // ← Cookie가 설정되어야 하는데...
...

결과적으로 발생했던 현상:

브라우저 ──► GET /auth/callback?code=xxx
↓
exchangeCodeForSession() ← Cookie가 설정되어야 하는데...
...

수정: response를 먼저 생성하고, 거기에 직접 Cookie를 설정

// ✅ 수정 후 (src/app/auth/callback/route.ts)
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
...

수정 A를 적용해도 드물게 다시 문제가 발생했습니다. 한 단계 더 깊은 버그가 있었습니다.

사용자가 /login에 있을 때, 지옥①의 "3초 타이머"가 백그라운드에서 동작하고 있습니다. "Google로 로그인"을 누르면 브라우저가 Google로 이동하며, JavaScript 컨텍스트(Context) 자체가 파기되므로 타이머는 취소됩니다. 하지만 stale Cookie(오래된 쿠키)는 그대로 남습니다.

t=0s /login 로드
t=0s getSession() 실행 + 3초 타이머 시작 (stale 토큰을 리프레시 중...)
t=1s 사용자가 "Google로 로그인"을 누름
...

새로운 세션이 오래된 Cookie에 의해 밀려나고 있었던 것입니다.

수정: Google 로그인 전에 stale Cookie를 삭제한 후 OAuth 시작

const signInWithGoogle = async () => {
if (!supabase) return;
// 동기적으로 sb-* Cookie를 모두 삭제한 후 OAuth 시작
...

"코드 생성하기", "Expo Snack에서 열기" 모두가 간헐적으로 504 Gateway Timeout을 반환하고 있었습니다. 원인은 각각 달랐습니다.

// ❌ 기존 코드
export const maxDuration = 60; // Vercel의 함수 타임아웃
for (let attempt = 0; attempt < 2; attempt++) {
...

계산해 보니 바로 알 수 있었습니다:

Claude Sonnet 4.6으로 12,000 토큰 생성 ≈ 회당 최대 40~50초
2회 시도 시 최악의 경우 = 80~100초 > maxDuration: 60초 → 504

첫 번째 시도에서 유효성 검사(Validation) 에러가 발생하더라도, 결정론적 폴백(Deterministic Fallback, generateReactNativeCode())이 존재하기 때문에 두 번째 시도는 불필요했습니다.

// ✅ 수정 후
export const maxDuration = 120; // 여유를 둠
// 1회만 시도. 실패 시 결정론적 폴백을 사용
...

React Native 구현 코드는 보통 4,000~6,000 토큰 내외이므로, 8,000이면 충분합니다.

Expo Snack API로의 fetch에 signal을 전달하는 것을 잊었습니다:

// ❌ Expo API가 느려지기만 할 뿐 Vercel의 60초 제한에 걸림
res = await fetch("https://exp.host/--/api/v2/snack/save", {
method: "POST",
...

외부 API로의 fetch에는 반드시 타임아웃을 설정할 것

AbortController를 사용하지 않으면, 외부 서비스가 느리거나 다운되었을 때 Vercel의 서버리스 함수(Serverless Function)가 제한 시간까지 계속 대기하게 되어 사용자에게 504 에러가 반환됩니다. 반드시 25~30초 정도의 타임아웃을 설정해야 합니다.

OAuth 실패 시(예: 인증 코드 만료), 콜백 루트(Callback Route)는 사용자를 /login?error=인증에 실패했습니다로 리다이렉트합니다. 이 에러를 로그인 페이지에서 표시하려고 했던 코드가 다음과 같습니다:

// ❌ 동작하지 않음
const [error, setError] = React.useState<string | null>(() => {
if (typeof window === "undefined") return null; // SSR 시에는 null
...

`"use client""

"use client"라고 작성한 컴포넌트라도, Next.js App Router는 첫 번째 렌더링을 서버 측에서 실행합니다. useState의 초기화 함수는 서버 측에서 호출되며(windowundefined이므로 null을 반환), 그 값이 hydration 시에 그대로 사용됩니다. 클라이언트에서 재실행되지 않습니다.

"use client라면 클라이언트에서만 동작한다" —— 이 오해에서 비롯된 버그입니다.

// ✅ useEffect를 통해 hydration 후에 읽기
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
...

Next.js App Router의 중요한 규칙

"use client"는 "이벤트 핸들러나 hooks를 사용한다"는 선언이지, "SSR(Server Side Rendering)을 하지 않는다"는 의미가 아닙니다. window / document / localStorage는 반드시 useEffect 내에서 사용해야 합니다.

릴리스 후, 이런 질문을 받았습니다.

"브라우저 검사 도구에서 코드와 데이터가 전부 보이는데, 보안상 괜찮은가요?"

이를 계기로 모든 API Route(19개)를 감사했습니다.

네트워크 탭에 보이는 것들
├── Supabase URL / anon key ← 공개를 전제로 한 설계. RLS가 보호함
├── Stripe 가격 ID ← 공개를 전제로 함. 결제는 서버 측에서 검증
...

Supabase anon key가 보여도 문제없는 이유: anon key는 "미인증 사용자로 접속하기" 위한 공개키입니다. 실제로 읽고 쓸 수 있는 데이터는 RLS (Row Level Security) 정책이 결정합니다. anon key를 알고 있더라도, RLS가 허용하지 않은 데이터에는 접근할 수 없습니다.

getUser() vs getSession() — 서버 측에서는 반드시 getUser()를 사용할 것

  • getSession() → 쿠키(Cookie)의 토큰을 그대로 신뢰함. 쿠키 변조에 취약함
  • getUser() → Supabase 서버에 문의하여 서버 측에서 검증함. 변조를 감지할 수 있음

Route Handler / Server Component에서는 반드시 getUser()를 사용해야 합니다.

경로인증 방법상태
/api/chatgetUser()
/api/generate-codegetUser()
/api/create-snackgetUser()
/api/import-codegetUser()
/api/design-diagnosisgetUser()✅ (수정 완료)
/api/gallery GET인증 불필요✅ 공개 갤러리
/api/gallery PATCHgetUser()
/api/share GET인증 불필요✅ 공개 공유 페이지
/api/share POSTgetUser()
/api/stripe/checkoutgetUser()
/api/stripe/webhookStripe 서명 검증
기타 Stripe 관련getUser()

부끄럽게도, 이 경로만 인증 체크를 완전히 잊고 있었습니다.

이 경로는 Anthropic의 Claude API를 호출하기 때문에, 인증 없이 누구나 무제한으로 API 크레딧을 소비할 수 있는 상태였습니다. 이를 발견했을 때 정말 아찔했습니다.

// ❌ 기존 코드 (인증 체크 없음)
export async function POST(req: NextRequest) {
const apiKey = process.env.ANTHROPIC_API_KEY;
...
// ❌ 브라우저의 Sources 탭에서 관리자 이메일 주소가 그대로 노출됨
const ADMIN_EMAILS = (process.env.NEXT_PUBLIC_ADMIN_EMAILS ?? "").split(",");

NEXT_PUBLIC_

이 접두사가 붙은 환경 변수는 빌드 시점에 JavaScript 번들(Bundle)에 포함됩니다. 관리자 확인 전용 서버 API를 만들고, 클라이언트 측의 이메일 리스트를 삭제했습니다:

// src/app/api/admin/check/route.ts
export async function GET() {
const { data: { user } } = await supabase.auth.getUser();
...
// ❌ Stripe 메타데이터에 user_id가 없는 경우, project_id(UUID)가 user_id 컬럼에 들어감
const userId = meta.user_id ?? overrides?.project_id; // 버그
// ✅ 폴백(Fallback)을 삭제. user_id가 없으면 조기 리턴(Early Return) (기존 코드가 올바르게 동작함)
...

이대로 방치하면 특정 조건에서 구독 정보가 손상될 가능성이 있었습니다.

// ❌ GitHub이 공개 저장소(Public Repository)라면 전 세계에 노출됨
const OWNER_IDS = new Set([
"8f9d659c-db9d-4b0b-a26d-7cae5ffc060b", // 이메일 주소까지 주석에 적어두었음
...

OWNER_USER_ID

OWNER_EMAIL

환경 변수로 이전하여 하드코딩을 완전히 제거했습니다.

버그근본 원인대책
인증 무한 로딩@supabase/ssrgetSession()이 Safari에서 멈춤Promise.race를 사용하여 3초 타임아웃 설정 + stale Cookie 삭제
Google OAuth 루프cookies()NextResponse.redirect()의 혼용으로 Cookie가 전달되지 않음response.cookies.set()을 사용
Google OAuth 루프 ②OAuth 버튼 클릭 시 stale Cookie가 잔류함OAuth 전에 clearSupabaseStorage()를 호출
504 타임아웃2회 시도 × 큰 max_tokens 값이 Vercel 제한을 초과함1회 시도 + 적절한 max_tokens + 여유 있는 maxDuration 설정
504 타임아웃 ②외부 API로의 fetch에 타임아웃이 없음AbortController로 반드시 타임아웃을 설정
에러가 표시되지 않음`

로컬에서 완벽하게 작동하던 코드라도, Safari와 특정 버전의 Supabase 조합에서는 멈춰버린다(hang). Expo Snack API가 특정 시간대에 느려지기도 한다. 이것들을 사전에 전부 방지하는 것은 현실적으로 불가능하다.

개인 개발에서의 "품질"이란 "완벽한 코드를 작성하는 것"이 아니라, **"무언가 고장 났을 때 빠르게 고칠 수 있는 체제를 만드는 것"**이 아닐까 지금은 생각하고 있다.

이를 위해 현재 하고 있는 것:

  • 콘솔에 의미 있는 로그를 심기[SparkAI] Auth initialization timed out와 같이, 어디서 무엇이 일어났는지 한눈에 알 수 있는 형태로
  • 에러는 "구체적인 메시지"로 사용자에게 전달하기 — "에러가 발생했습니다"가 아니라 "Expo Snack으로의 연결이 타임아웃되었습니다. 잠시 후 다시 시도해 주세요."와 같이
  • 절대로 UI가 완전히 죽지 않는 설계로 만들기 — 인증이 고장 나더라도 "새로고침 버튼"만은 나오는, 최후의 방어선 구축

"작동하는 것을 빠르게 내놓는 것"을 우선시한 결과, 인증 버그·타임아웃·보안 취약점(Security Hole)이 겹쳤다. 하지만 각각의 문제를 추적하며 근본 원인을 찾아내는 과정에서, @supabase/ssr의 내부 구현이나 Next.js App Router의 SSR 동작에 대해 문서만 읽어서는 얻을 수 없는 깊은 이해를 얻을 수 있었다.

버그는 부끄러운 것이지만, 전부 메모해서 글로 남기면 누군가의 지뢰 회피 지도가 될 수 있다 —— 그렇게 생각하며 전부 작성했다. 같은 스택으로 개인 개발을 하고 계신 분들에게 참고가 된다면 기쁘겠다.

SparkAI — 일본어로 스마트폰 앱을 만들 수 있는 서비스

질문·피드백·개발 진행 상황은 X에서 발신하고 있습니다 → @naokaihatu

SparkAI의 개발 뒷이야기도 그곳에서 트윗하고 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0