← Back to Devlog
🚀2026년 06월 15일 (Monday)

Storypia에서 동화모아로: 한 줄로 그림책을 만들고 책장에 꽂히기까지

Storypia를 동화모아로 리브랜딩하고, 생성 엔진을 Cloud Run에 올리고, 만든 책이 내 구글 드라이브와 도토리 책장에 저장되게 만든 프로덕션 구축기. 무료 한도 안에서 Supabase·Drive를 엮고, 관리자 연재까지 붙인 이야기.

지난 글에서 Storypia를 띄웠다. 한 줄을 넣으면 그림책 한 권이 나오는 서비스였지만, 그땐 생성 엔진이 내 노트북에서만 돌았다. 사이트는 떠 있는데 정작 "책 만들기"를 누르면 Load failed. 이번엔 그걸 진짜 서비스로 만들었다. 이름도 바꿨다.

Storypia → 동화모아. 화면에 보이는 이름은 순한글 "동화모아", 코드·도메인은 "StoryMoa".


🪧 왜 이름을 바꿨나

"Storypia"는 발음도, 뜻도 한 번에 안 꽂혔다. 한국 부모가 쓰는 그림책 서비스인데 영문 조어를 쓸 이유가 없었다. 동화 + 모아(MoaHub) = 동화모아. 모아허브 브랜드 위에 자연스럽게 얹혔다.

리브랜딩은 생각보다 손이 많이 갔다. 폴더(storypia/storymoa/), 도메인(storymoa.moahub.co.kr), 화면 문구, 메타 태그까지 전부. 단 일부러 안 바꾼 것들이 있다:

  • 사용자 구글 드라이브의 책 보관 폴더명 — 바꾸면 기존 책이 미아가 된다
  • 내부 연동 식별자(env, 백엔드 이름) — 갈아엎을 이유 없는 곳

리네이밍의 8할은 "어디까지 바꾸지 않을지"를 정하는 일이었다.

리다이렉트도 안 걸었다. 옛 주소는 새 주소가 자리 잡으면 통째로 없앨 거라서.


🚀 엔진을 Cloud Run에 올리다

가장 큰 숙제. 책 생성 엔진은 Python(FastAPI + OpenAI gpt-4o/gpt-image-1)으로, 한 권에 2~4분, 약 $1이 든다. 이걸 공개 서버에 올려야 사이트에서 진짜로 책이 나온다.

선택은 Cloud Run. 이유:

  • scale-to-zero — 아무도 안 쓰면 인스턴스 0개, 비용 0원
  • 잡(job)이 메모리에 떠 있는 구조라 인스턴스 1개 고정(--max-instances 1)이 필요했는데 Cloud Run이 딱 맞았다
  • 생성이 백그라운드로 도니 --no-cpu-throttling, 한 권 4분이니 --timeout 900

도커 이미지엔 한글 PDF용 나눔·노토CJK 폰트를 apt로 심었다. 그리고 신규 GCP 프로젝트의 단골 함정 — 빌드 서비스 계정에 권한이 없어 첫 배포가 PERMISSION_DENIED로 깨졌다. roles/cloudbuild.builds.builder를 부여하고 재배포하니 통과.

gcloud run deploy storymaker --source . --region asia-northeast3 \
  --allow-unauthenticated --memory 2Gi --cpu 2 --timeout 900 \
  --max-instances 1 --no-cpu-throttling \
  --set-env-vars OPENAI_API_KEY=...

작은 함정 하나 더: .env의 키가 따옴표로 감싸여 있어, 그대로 넘기면 따옴표째 환경변수에 들어가 인증이 실패했다. 따옴표를 벗겨서 넘기는 걸로 해결.


🧩 만든 책이 사용자에게 도달하는 경로

로컬에선 엔진이 PDF를 만들면 같은 디스크의 Next.js public/ 폴더로 복사했다. 그런데 Cloud Run은 Vercel의 디스크에 못 쓴다. 둘은 완전히 다른 서버다. 그래서 완성 화면의 "책 보기"가 엉뚱한 샘플 책을 열거나 다운로드가 깨졌다.

해결은 단순했다. 엔진이 만든 PDF를 엔진이 직접 HTTP로 내보내게 했다.

  • 백엔드: GET /api/jobs/{id}/pdf (보기는 inline, 다운로드는 ?dl=1로 attachment), 표지는 /cover
  • 프론트: 완료 이벤트의 pdf_url을 그대로 사용

로컬 개발의 "같은 디스크" 가정은 분산 배포에서 가장 먼저 깨지는 약속이다.


🗂 무료 한도 안에서 책장을 만들다

만든 책은 사용자의 구글 드라이브에 저장된다(드라이브 drive.file 스코프 — 우리 앱이 만든 파일만 접근). 책의 메타데이터(제목·표지·쪽수)는 어딘가 DB가 필요했다. Supabase.

문제: Supabase 무료 플랜은 프로젝트 2개까지. 나는 이미 학습/여행 시리즈로 2개를 쓰고 있었다. 그래서 새로 만들지 않고 기존 "학습 AI 시리즈" 프로젝트에 테이블만 얹었다. 같은 무료 한도 안에서 앱 하나를 더 태운 셈.

여기서 또 하나 배웠다. 이 앱은 NextAuth(구글 로그인, JWT)를 쓰는데, 스키마는 Supabase Auth의 auth.users(UUID)를 참조하도록 짜여 있었다. 둘이 안 맞는다. 사용자 식별자는 구글의 sub(숫자 문자열)이라 UUID가 아니다.

user_id를 UUID·외래키가 아니라 그냥 TEXT(구글 sub)로 뒀다. 인증 시스템 두 개를 억지로 결혼시키지 않는 게 정답이었다.

서버는 service_role 키로만 접근하고 user_id를 직접 필터링한다. 로그인은 구글 한 번으로 끝(관리자 계정은 결제 없이 무제한 — 테스트용).


🌰 도토리 책장

만든 책이 쌓이는 곳, "도토리 책장". 처음엔 3개의 선반(오늘 밤 잠들기 전에 / 둘이서 함께 / 다시 펼쳐보고)에 드래그로 분류하는 안을 짰는데, 만들다 보니 과했다.

단순함이 이겼다. 선반 하나, 책이 많아지면 가로 스크롤. 끝.

여기에 붙인 것들:

  • 표지 이미지 — 엔진이 PDF 첫 장을 PNG로 뽑아 드라이브에 같이 올리고, 책장 카드에 진짜 표지로 보여준다
  • "5분" 같은 모호한 정보 대신 쪽수 표시
  • 인라인 그림책 뷰어 — 펼침면으로 넘겨 읽기. (페이지 비율이 안 맞아 본문 아래가 잘리던 버그를 잡느라, 실제 PDF가 2:3인데 컨테이너가 1.33이었다는 걸 한참 만에 발견했다)
  • 책 다운로드·삭제

📰 관리자 연재

마지막으로, 내가 만든 책 중 좋은 걸 "연재 동화"라는 공개 갤러리에 올릴 수 있게 했다. DB의 visibilitypublic으로 토글하면 끝.

여기서도 drive.file 스코프의 벽. 책 PDF는 내 드라이브에 비공개로 있어서, 로그인 안 한 방문자는 못 본다. 그래서 연재할 때 그 파일을 "링크 있는 누구나 보기"로 공개하고, 우리 서버가 공개 파일을 대신 받아 스트림하는 경로를 따로 뒀다(브라우저 CORS·다운로드 인터스티셜 회피).


🪞 돌아보며

이번 라운드는 "데모"와 "서비스" 사이의 거리를 메우는 일이었다. 그 거리의 대부분은 화려한 기능이 아니라 분산 배포에서 깨지는 가정들 — 같은 디스크, 같은 인증, 같은 권한 — 을 하나씩 다시 잇는 일이었다.

  • 로컬의 편의(파일 복사)는 프로덕션에서 HTTP 경계로 바뀐다
  • 인증 두 개(NextAuth·Supabase Auth)는 합치지 말고 식별자만 통과시킨다
  • 무료 한도는 "프로젝트를 더 만들지 않는" 설계로 늘린다

다음은 일반 사용자가 실제로 결제하고 만들 수 있게 하는 결제 연동. 그게 붙으면 동화모아는 데모를 완전히 졸업한다.

한 줄을 넣으면, 우리 아이가 주인공인 그림책 한 권이 책장에 꽂힌다. 이제 그게 내 노트북이 아니라 어디서나 된다.