본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 21. 14:31

PostAll 구축하기: 콘텐츠 자동화 SaaS를 공개적으로 개발하는 첫 번째 주

요약

콘텐츠 자동화 SaaS인 PostAll을 공개적으로 개발(Building in Public)하기 위한 첫 번째 주차 기록입니다. 단순한 텍스트 생성을 넘어 CMS에 즉시 게시 가능한 수준의 구조화된 콘텐츠를 만드는 것을 목표로 하며, Next.js, PostgreSQL, BullMQ, OpenAI API 등을 활용한 기술 스택 결정 과정을 다룹니다.

핵심 포인트

  • PostAll의 핵심 가치는 단순 AI 텍스트 생성이 아닌, CMS에 즉시 적용 가능한 구조화된 데이터(헤딩 계층, 메타데이터 등)를 생성하는 엔지니어링에 있음
  • 기술 스택으로 Next.js 14, PostgreSQL(Prisma), BullMQ, Redis, OpenAI API를 선택하여 확장성과 데이터 무결성을 고려함
  • 특정 플랫폼에 종속되지 않기 위해 백엔드 아키텍처를 Supabase 전체 사용 대신 인증 용도로만 제한하고 분리(Decoupling)함
  • 비동기 콘텐츠 생성을 위해 BullMQ와 Redis를 활용한 작업 큐(Job queue) 아키텍처를 도입함

저는 너무 오랫동안 비공개로 개발해 왔습니다. 2개월간의 단독 개발, 공개 커밋(public commits) 0건, 그리고 "준비되면 공유하겠다"라는 생각이 사실은 생산성이라는 가면을 쓴 두려움일 뿐이라는 의구심이 커지고 있었습니다. 그래서 저는 이 습관을 깨기로 했습니다. 지금부터 저는 Dev.to에서 콘텐츠 자동화 도구인 PostAll을 공개적으로 개발(building in public)할 것입니다. 매주 저는 이 글을 작성할 것입니다. 하이라이트 영상이 아닌, 실제 기술적 결정, 실제 막다른 길, 그리고 실제 코드를 다룰 것입니다. 1주 차가 지난 지금의 상황은 다음과 같습니다.

PostAll의 실제 정체 (기술적 버전)
PostAll은 사용자의 콘텐츠 브리프(content brief)—주제, 브랜드 보이스(brand voice), 타겟 오디언스(target audience), 출력 형식—를 받아 구조화된, 즉시 게시 가능한 콘텐츠를 대규모로 생성하는 SaaS 플랫폼입니다. "즉시 게시 가능한"이라는 말에는 많은 의미가 담겨 있으므로 구체적으로 말씀드리겠습니다. 목표는 단순히 GPT의 가공되지 않은 출력물을 텍스트 박스에 쏟아붓고 끝내는 것이 아닙니다. 목표는 CMS(Content Management System)에 바로 사용할 수 있는 출력물을 만드는 것입니다. 즉, 올바른 헤딩 계층 구조(heading hierarchy), 메타데이터(metadata) 채우기, 내부 링크 플레이스홀더(internal link placeholders) 표시, 브랜드 가이드에 따른 톤 체크, 그리고 특정 플랫폼에 맞춘 포맷팅을 포함합니다. 그것이 바로 제품입니다. "AI가 텍스트를 쓴다"와 "AI가 당신의 CMS가 직접 수용할 수 있는 텍스트를 쓴다" 사이의 간극이 바로 PostAll이 존재하는 지점이며, 이는 대부분의 사람들이 예상하는 것보다 훨씬 더 큰 엔지니어링적 간극입니다.

내가 선택한 스택 (그리고 거의 선택할 뻔했던 스택)
이 부분에 대해서는 인정하고 싶지 않을 정도로 오랫동안 고민했습니다.

현재 사용 중인 기술:

  • Next.js 14 (App Router) — 프론트엔드(Frontend) 및 API 라우트(API routes). 시작 단계에서는 하나의 코드베이스를 원했습니다. 병목 현상이 발생할 경우/때가 되면 API를 별도의 서비스로 분리할 것입니다.
  • PostgreSQL + Prisma — 사용자, 워크스페이스(workspaces), 콘텐츠 작업(content jobs), 출력 이력을 위한 관계형 데이터(Relational data). 콘텐츠 스키마(content schemas)의 유연성을 위해 MongoDB를 고려하기도 했지만, 관계형 제약 조건이 버그가 아닌 기능(feature)이라는 점이 밝혀졌습니다. 이에 대해서는 아래에서 더 자세히 다루겠습니다.
  • BullMQ + Redis — 비동기 콘텐츠 생성을 위한 작업 큐(Job queue).
  • OpenAI API (gpt-4o) — 현재 주력 생성 모델.
  • Vercel — 호스팅(Hosting). 네, 알고 있습니다.

Vercel의 가격 산정 방식이 저에게 불리해지는 시점이 오면 VPS (Virtual Private Server)로 이전할 것입니다. 제가 거의 선택할 뻔했던 것: 백엔드 전체를 Supabase로 구성하는 것이었습니다. 작업 상태 업데이트를 위한 Realtime subscriptions (실시간 구독) 기능이 문서상으로는 매우 훌륭해 보였습니다. 결국 인증 (Auth) 용도로는 Supabase를 유지했지만, 이를 전체 백엔드로 사용하는 것은 철회했습니다. 작업 큐 (Job queue) 아키텍처를 특정 플랫폼에 결합(Coupling)시키고 싶지 않았기 때문입니다. 이러한 결정을 초기에 분리(Decoupling)한 것은 옳은 선택이었다고 느낍니다.

첫 번째 실제 문제: 콘텐츠 작업 상태는 속일 수 없다
콘텐츠 생성 튜토리얼에서 아무도 말하지 않는 사실이 있습니다. 콘텐츠 작업 (Content job)은 단순한 요청 (Request)이 아니라는 점입니다. 그것은 상태 머신 (State machine)입니다. 사용자가 브리프 (Brief)를 제출하면, 해당 작업은 완료되기 전까지 최소 6가지 상태를 거칩니다: QUEUED (대기 중) → PROCESSING (처리 중) → GENERATING (생성 중) → VALIDATING (검증 중) → FORMATTING (포맷팅 중) → COMPLETE (완료).

그리고 각 단계에서 서로 다른 이유로 실패할 수 있으며, 이에 따라 서로 다른 복구 전략 (Recovery strategies)이 필요합니다. GENERATING 단계의 실패는 FORMATTING 단계의 실패와 다릅니다. 전자는 다른 프롬프트로 재시도해야 함을 의미할 수 있고, 후자는 출력물은 잘 생성되었으나 후처리 (Post-processing) 과정에서 문제가 발생했음을 의미할 수 있습니다.

저의 첫 번째 구현 방식은 정확히 두 가지 상태, 즉 pending (대기 중)과 done (완료)만 있었습니다. 당시 스키마 (Schema)는 다음과 같았습니다:

// 1주 차, 1일 차 — 너무 단순한 모델

model ContentJob {
  id            String   @id @default(cuid())
  status        String   // "pending" | "done" | "failed"
  output        String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

이 방식은 부분적인 실패, 즉 생성은 성공했지만 포맷팅에서 오류가 발생하는 상황을 처리하려 할 때 한계에 부딪혔습니다. 포맷팅 단계만 재시도할 방법이 없었습니다. 작업 전체가 실패로 표시되었고, 생성된 콘텐츠는 사라져 버렸습니다. 3일 차가 되었을 때, 스키마는 다음과 같이 변경되었습니다:

// 1주 차, 3일 차 — 실제 상황을 모델링함

model ContentJob {
  id               String         @id @default(cuid())
  status           ContentStatus  // Prisma 스키마의 enum
  stage            ContentStage   // 현재 어느 단계인지
  generatedOutput  String?        // 원본 LLM 출력, 별도로 보존
  formattedOutput  String?        // 후처리된 출력
  failureReason    String?

retryCount Int @ default ( 0 )
lastAttemptAt DateTime ?
createdAt DateTime @ default ( now ())
updatedAt DateTime @ updatedAt
}

enum ContentStatus {
QUEUED
PROCESSING
COMPLETE
FAILED
RETRYING
}

enum ContentStage {
GENERATION
VALIDATION
FORMATTING
}

상태 (Status)를 단계 (Stage)와 분리한 것이 핵심적인 통찰이었습니다. 상태는 "이 작업이 정상적인가?"를 나타내고, 단계는 "파이프라인의 어느 지점에 있는가?"를 나타냅니다. 이제 저는 FORMATTING 단계에서 작업을 실패하더라도, 생성된 콘텐츠를 잃지 않고 해당 단계만 재시도할 수 있습니다. 여기서 얻은 교훈은 콘텐츠 생성 그 이상에 적용됩니다. 만약 여러분의 워크플로 (Workflow)가 두 개 이상의 의미 있는 상태를 가진다면, 첫날부터 이를 명시적으로 모델링하십시오. status: string 컬럼에 상태 머신 (State Machine)을 사후에 끼워 맞추는 것은 매우 고통스러운 일입니다.

큐 (The Queue): 왜 단순한 setTimeout이 아닌 BullMQ인가

솔직한 답변은 setTimeout으로 시작했다는 것입니다. 사용자 한 명(나 자신)뿐인 로컬 개발 환경에서는 괜찮았습니다. 콘텐츠 작업이 들어오면, 비동기 함수 (Async function)를 실행하고, 그것이 실행된 뒤 완료됩니다. 간단하죠. 그러다 문득 이런 생각이 들었습니다. 만약 생성 도중에 서버가 재시작되면 어떻게 될까? 작업은 사라집니다. 사용자는 무슨 일이 일어났는지 알 수 없고, 저는 무엇이 실행 중이었는지 가시성을 확보할 수 없습니다. Redis를 사용하는 BullMQ는 저에게 다음과 같은 기능을 제공합니다:

  • 지속성 (Persistence) — 서버 재시작 후에도 작업이 유지됨
  • 가시성 (Visibility) — 큐의 깊이 (Queue depth), 처리 시간, 실패율을 확인할 수 있음
  • 재시도 로직 (Retry logic) — 지수 백오프 (Exponential backoff) 내장
  • 동시성 제어 (Concurrency control) — 한 번에 X개의 작업을 처리하여 OpenAI API에 과부하를 주지 않도록 조절 가능

실제 워커 (Worker) 설정은 다음과 같습니다:

// src/lib/queue/contentWorker.ts
import { Worker, Job } from 'bullmq';
import { generateContent } from '../generation/generateContent';
import { formatOutput } from '../formatting/formatOutput';
import { updateJobStatus } from '../db/contentJobs';
import { redisConnection } from '../redis';

const CONCURRENCY = 3; // 현재 내 티어의 OpenAI gpt-4o 속도 제한 (Rate limit) 버퍼

export const contentWorker = new Worker('content-generation', async (job: Job) => {
  const { jobId, brief } = job.

data ; try { // Stage 1: Generation await updateJobStatus ( jobId , ' PROCESSING ' , ' GENERATION ' ); const rawOutput = await generateContent ( brief ); await saveGeneratedOutput ( jobId , rawOutput ); // Stage 2: Formatting await updateJobStatus ( jobId , ' PROCESSING ' , ' FORMATTING ' ); const formatted = await formatOutput ( rawOutput , brief . outputFormat ); // Complete await updateJobStatus ( jobId , ' COMPLETE ' , ' FORMATTING ' , { formattedOutput : formatted }); } catch ( error ) { // Preserve which stage we were in when it failed await updateJobStatus ( jobId , ' FAILED ' , job . data . currentStage , { failureReason : error instanceof Error ? error . message : ' Unknown error ' , }); throw error ; // Re-throw so BullMQ handles retry logic } }, { connection : redisConnection , concurrency : CONCURRENCY , limiter : { max : 3 , // Max 3 jobs processed duration : 1000 , // per second — OpenAI rate limit guard }, } ); 이 동시성(concurrency): 3 및 제한기(limiter) 설정은 임의적인 것이 아닙니다. 현재 제가 사용하는 OpenAI API 티어에 맞춰 특별히 조정된 것입니다. 티어가 높아지면 이 값을 올릴 계획입니다.

제가 과소평가했던 것: 프롬프트 가변성
저는 콘텐츠 생성 도구에서 가장 어려운 부분이 인프라라고 생각했습니다. 틀렸습니다. 어려운 부분은 프롬프트 일관성입니다. 동일한 간략 설명(brief)을 같은 프롬프트로 10번 실행해도, 매번 똑같은 출력을 얻지 못합니다. 구조가 다르고, 어조 강조점이 다르고, 제목 선택이 다른 10가지 변형을 얻게 됩니다. 단일 콘텐츠 조각에는 괜찮습니다. 다양성은 기능이니까요. 하지만 동일한 브랜드에서 나온 것처럼 들리는 제품 설명 200개가 필요한 비즈니스에게는 심각한 문제입니다.

이번 주 후반부에는 제가 "구조화된 생성 (structured generation)"이라고 부르는 작업에 시간을 보냈습니다. 기사 전체를 한 번에 프롬프트로 요청하는 대신, 생성을 다음과 같은 구조화된 하위 호출 (sub-calls)로 나누는 방식입니다:

  1. 개요 생성 (스키마에 따라 검증된 JSON 출력)
  2. 개요의 각 섹션을 독립적으로 생성
  3. 최종 일관성 검사 (consistency pass) 수행 및 실행

이 방식은 더 느리고 작업당 비용도 더 많이 듭니다. 하지만 일관성은 훨씬 더 극적으로 높아집니다. 다음 주에 벤치마크 (benchmarks) 결과를 공유하겠습니다.

나를 놀라게 한 숫자
이번 주에 큐 (queue)를 통해 47개의 테스트 작업을 실행했습니다. gpt-4o를 사용하여 약 800단어 분량의 기사를 생성하는 데 걸린 평균 시간은 작업당 11.3초였습니다. 규모를 생각하면 이는 매우 빠른 것처럼 들리지만, 실제로는 그렇지 않습니다. 현재의 동시성 (concurrency) 수준에서 이 속도로 500개의 기사를 생성하려면 약 94분이 소요됩니다. 배치 작업 (batch jobs)에는 괜찮지만, 사용자가 제출 후 20분 이내에 대시보드에서 500개의 기사를 확인하기를 기대한다면 문제가 됩니다.

다음 주의 과제: 11초가 실제로 어디에 소요되는지 (API 지연 시간 (latency) vs. 내 코드), 그리고 구조화된 생성이 섹션 호출을 병렬 (parallel)로 실행할 수 있는지 파악하는 것입니다.

다음 주 예정 사항

  • 실제 벤치마크 수치를 포함한 구조화된 생성 아키텍처 (하위 호출 방식)
  • 포맷팅 레이어 (formatting layer): 가공되지 않은 LLM 출력을 CMS에 바로 사용할 수 있는 HTML/Markdown으로 변환하는 방법
  • 첫 번째 베타 사용자 온보딩 (onboarding) — 제 인맥 중 한 명이 이번 주에 테스트해 보기로 했습니다.

내가 실제로 답을 모르는 열린 질문
작업 상태 업데이트를 Server-Sent Events (SSE)를 통해 프론트엔드로 푸시(push)해야 할지, 아니면 베타 버전에서는 3초마다 폴링 (polling)을 하는 것으로 충분할지 고민 중입니다. SSE가 더 우아합니다. 폴링은 디버깅하기 더 쉽습니다. 3초 간격이라면 폴링은 사용자에게 거의 느껴지지 않을 것입니다. 하지만 무언가 잘못된 느낌이 듭니다. 10~15초가 소요되는 콘텐츠 생성 작업에 여러분이라면 무엇을 선택하시겠습니까? 댓글로 남겨주세요. 다수의 의견을 따를 것이며 다음 주에 결과를 보고하겠습니다.

PostAll은 현재 공개 베타 (public beta) 상태입니다. 콘텐츠 워크플로우를 구축하는 개발자로서 조기 액세스 (early access)를 원하신다면, 여기에 답글을 남기거나 DM을 보내주세요. 다음 개발 로그 (devlog)가 곧 올라옵니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0