PostAll의 콘텐츠 파이프라인은 실제로 어떻게 작동하는가: 전체 아키텍처 분석
요약
콘텐츠 자동화 플랫폼 PostAll의 내부 아키텍처를 5가지 레이어(큐, 오케스트레이션, LLM, 포맷팅, CMS 커넥터)로 나누어 상세히 분석합니다. 실제 프로덕션 환경에서 발생할 수 있는 문제점과 BullMQ를 활용한 작업 관리 및 중복 제거 전략을 공유합니다.
핵심 포인트
- PostAll은 5개의 독립적인 레이어로 구성된 아키텍처를 가짐
- Redis 기반의 BullMQ를 사용하여 높은 처리량과 가시성 확보
- 결정론적 jobId 패턴을 통한 작업 중복 제거의 중요성
- 모놀리스 구조가 아닌 레이어별 독립 배포 및 장애 관리 권장
제가 구축해 온 콘텐츠 자동화 플랫폼인 PostAll의 전체 내부 아키텍처를 보여드리려고 합니다. 사람들이 공개적으로 AI 콘텐츠 도구에 대해 이야기하는 방식에 간극이 있다고 생각하기 때문입니다.
대부분의 창업자들은 결과물 측면에서 제품을 설명합니다: "10배 더 빠르게 콘텐츠 생성", "일관된 브랜드 보이스", "팀과 함께 확장 가능". 랜딩 페이지용으로는 괜찮습니다. 하지만 유사한 것을 구축할지 평가하는 개발자이거나, AI 콘텐츠 파이프라인이 실제로 대규모로 작동하게 만드는 요인이 무엇인지 이해하려는 사람에게 결과 중심의 화법은 아무런 정보도 주지 못합니다.
그래서 제가 시작할 때 읽었어야 했던 내용을 공유합니다: 실제 프로덕션 콘텐츠 자동화 시스템이 어떻게 구조화되어 있는지, 각 레이어(layer)가 무엇을 하는지, 어디에서 문제가 발생하는지, 그리고 제가 그 과정에서 내렸던 결정과 후회하는 부분들입니다.
이것은 튜토리얼이 아닙니다. 투명성을 위한 게시물입니다. PostAll은 현재 운영 중입니다. 이것이 작동 방식입니다.
30초 요약 버전
PostAll은 다섯 가지의 별도 레이어로 구성됩니다:
- 큐 시스템 (Queue system) — 작업을 수집하고, 우선순위를 지정하며, 재시도(retries)를 처리합니다.
- 오케스트레이션 레이어 (Orchestration layer) — 콘텐츠 요청을 원자적 작업(atomic tasks)으로 분해합니다.
- LLM 레이어 (LLM layer) — 모델을 호출하고, 속도 제한(rate limits) 및 폴백(fallbacks)을 처리합니다.
- 포맷팅 엔진 (Formatting engine) — 가공되지 않은 LLM 출력을 구조화된 콘텐츠로 변환합니다.
- CMS 커넥터 (CMS connectors) — 포맷팅된 콘텐츠를 클라이언트가 원하는 곳으로 전송합니다.
각 레이어는 독립적으로 배포 가능하며 각기 다른 장애 모드(failure modes)를 가집니다. 제가 초기에 저지른 가장 큰 실수는 이들을 하나의 모놀리스(monolith)로 취급한 것이며, 무엇이 정확히 고장 났는지 설명하겠습니다.
큐 시스템 (The queue system)
역할
단 하나의 블로그 포스트든 500개의 제품 설명이든, 모든 콘텐츠 요청은 PostAll에 작업(job)으로 들어옵니다. 작업은 다른 어떤 일이 일어나기 전에 큐(queue)에 도달합니다. 큐는 어떤 작업이 존재하는지, 무엇이 진행 중인지, 무엇이 실패했는지를 나타내는 단일 진실 공급원(single source of truth)입니다.
저는 이를 위해 BullMQ (Redis 기반)를 사용합니다. 처음에는 Postgres 기반의 큐(pg-boss, Graphile Worker)를 검토했습니다. 처리량(throughput)이 낮은 경우에는 괜찮습니다. 하지만 규모가 커지면 Redis 기반 방식이 급격한 트래픽 증가(bursts)를 더 잘 처리하며, BullMQ UI의 작업 가시성(job visibility) 도구 덕분에 프로덕션 환경에서 디버깅 시간을 크게 절약할 수 있었습니다.
// job-producer.ts
import { Queue } from 'bullmq';
import { redis } from './redis-client';
...
jobId 패턴이 중요합니다. 이를 결정론적(deterministic) 문자열로 설정하면, 클라이언트가 중복 제출을 하더라도(네트워크 재시도, 사용자의 중복 클릭 등) BullMQ가 조용히 중복을 제거(deduplicate)합니다. 반드시 이렇게 해야 합니다. 그렇지 않으면 금요일 오후에 왜 동일한 기사가 세 번이나 생성되었는지 추적하는 데 시간을 허비하게 될 것입니다.
내가 실수했던 점: 워커 동시성 (worker concurrency)
나의 초기 워커 설정:
// 잘못된 방식 — 이렇게 하지 마세요
const worker = new Worker('content-generation', processJob, {
connection: redis,
...
50개의 워커가 동시에 OpenAI를 호출하자, 지속적으로 속도 제한(rate limit)에 걸려 포화 상태가 되었습니다. 실패한 요청에 대한 재시도(retries)가 피드백 루프를 유발했습니다: 더 많은 재시도 → 더 많은 실패 → 더 많은 재시도.
해결책:
// 올바른 방식 — 실제 API 티어에 맞춰 크기 조절
const worker = new Worker('content-generation', processJob, {
connection: redis,
...
BullMQ의 limiter 옵션은 제대로 활용되지 않고 있습니다. 이는 단순히 개별 워커에 대한 설정이 아니라, 실행 중인 모든 워커 인스턴스에 걸쳐 적용되는 전역 속도 조절기(global rate governor)입니다. 오토스케일링(autoscaling)을 사용하면 10개의 포드(pod)가 모두 동일한 큐에서 작업을 가져올 수 있습니다. limiter가 없다면 10개 포드 × 50개 동시성 = 500개의 동시 LLM 호출이 발생합니다. 이렇게 하면 한 시간 만에 3,000달러의 청구서와 고객 지원 티켓을 동시에 받게 될 것입니다.
오케스트레이션 계층 (The orchestration layer)
역할
"콘텐츠 작업(content job)"은 원자적(atomic)인 것처럼 들리지만, 그렇지 않습니다. PostAll에서의 단일 블로그 포스트 요청은 다음과 같이 분해됩니다:
- 키워드 조사 작업 (활성화된 경우)
- 개요 생성 작업 (Outline generation task)
- 섹션 초안 작성 (섹션당 하나씩, 병렬 처리 가능)
- 서론/결론 결합 작업 (Introduction/conclusion stitching task)
- SEO 메타데이터 생성 작업
- 품질 점수 평가 작업 (Quality score evaluation task)
오케스트레이션 계층 (orchestration layer)은 이러한 분해 작업을 처리하고, 작업 의존성 (task dependencies)을 추적하며, 결과를 일관된 출력물로 집계합니다.
저는 Temporal이나 Inngest와 같은 워크플로 엔진 (workflow engine)을 사용하는 대신, 간단한 DAG (directed acyclic graph, 유향 비순환 그래프) 구조로 이를 구축했습니다. PostAll의 사용 사례에서는 목적에 맞게 제작된 DAG가 운영하기에 더 간단하고 저렴했습니다. 만약 인간 참여형 (human-in-the-loop) 단계가 포함된 멀티 테넌트 (multi-tenant) 워크플로를 구현해야 한다면 다시 고려해 볼 것입니다.
// content-dag.ts
type TaskNode = {
id: string;
...
실행기 (executor)는 DAG를 위상 정렬 (topologically)하여 해결하고, 독립적인 노드들을 병렬로 실행하며, context를 통해 결과를 전달합니다:
// dag-executor.ts
async function executeDag(nodes: TaskNode[], jobId: string): Promise<JobContext> {
const context: JobContext = { jobId, results: {}, errors: {} };
...
Promise.allSettled를 선택한 것은 의도적인 결정입니다. 한 섹션이 실패하더라도 다른 섹션들은 완료되기를 원하기 때문입니다. 500단어 분량의 한 섹션에서 오류가 발생하여 모든 작업 내용을 잃는 것보다, 마지막에 부분적인 결과와 함께 작업을 실패 처리하는 것이 훨씬 더 낫습니다.
LLM 계층 (The LLM layer)
역할
PostAll의 모든 LLM 호출은 단일 추상화 계층인 LLMClient를 거칩니다. 이 클래스는 모델 선택, 프롬프트 구성 (prompt construction), 재시도 로직 (retry logic), 폴백 라우팅 (fallback routing) 및 비용 추적 (cost tracking)을 처리합니다. 코드베이스 내에서 OpenAI를 직접 호출하는 부분은 없습니다.
// llm-client.ts
export class LLMClient {
private providers: ProviderConfig[];
...
폴백 라우팅 설정 (The fallback routing config)
// llm-config.ts
export const llmConfig: LLMClientConfig = {
providers: [
...
멀티 프로바이더 (multi-provider) 폴백은 복잡하게 들릴 수 있지만, 대부분은 단순히 순서를 정하는 문제입니다. 제가 도달하기까지 너무 오래 걸렸던 통찰은 다음과 같습니다: 호출 시점에 라우팅을 똑똑하게 처리하려고 애쓰지 마세요. 우선순위 목록을 유지하고, 순서대로 시도하며, 하나가 실패하면 다음으로 넘어가세요. 스마트 라우팅 (지연 시간 기반, 비용 기반)은 현재 저의 규모에서는 실질적인 이득 없이 복잡성만 더했습니다.
토큰 예산 책정 (Token budgeting)
제가 초기에 겪었던 문제 중 하나는 다음과 같습니다. 긴 형식의 콘텐츠 (long-form content)를 위한 LLM 요청은 작업 생성 시점에 예측하기 어려운 방식으로 컨텍스트 제한 (context limits)을 초과할 수 있습니다. PostAll은 이제 각 호출 전에 토큰 사용량을 추정합니다:
function estimatePromptTokens(messages: Message[]): number {
// 대략적인 추정: 영어 산문의 경우 1 토큰 ≈ 4자
const charCount = messages.reduce((sum, m) => sum + m.content.length, 0);
...
이를 통해 조용한 잘림 (silent truncation) 현상을 방지할 수 있습니다. 이는 컨텍스트 창 (context window)이 소진되어 모델이 문장 중간에 그냥... 멈춰버리는데, 왜 그런 일이 발생했는지 알 수 없는 실패 모드 (failure mode)를 의미합니다.
포맷팅 엔진 (The formatting engine)
역할
LLM의 가공되지 않은 출력 (Raw LLM output)은 텍스트입니다. 클라이언트에게 실제로 필요한 것은 CMS에 바로 붙여넣을 수 있는 구조화되고 포맷팅된 콘텐츠입니다. 즉, 적절한 헤딩 계층 구조 (heading hierarchy), 올바른 형식의 내부 링크 (internal links), 글자 수 제한 내의 메타 설명 (meta descriptions), 이미지 대체 텍스트 (image alt text) 제안 등이 포함되어야 합니다.
포맷팅 엔진은 이러한 변환을 처리합니다. 이 엔진은 콘텐츠를 생성하는 모든 LLM 호출(메타데이터 호출 제외) 이후에 실행됩니다.
// formatter.ts
export type FormatTarget = 'markdown' | 'html' | 'wordpress' | 'contentful-richtext';
...
이 전체 스택에서 가장 과소평가된 기능은 normalizeHeadings입니다. LLM은 헤딩 레벨 (heading levels)에 대해 일관성이 없습니다. H3 섹션 안에 H1이 나타나거나, H2보다 H4가 먼저 등장하기도 하며, 때로는 구조가 아예 없을 때도 있습니다. 단순한 정규 표현식 (regex) 한 번으로는 이를 해결할 수 없으며, 트리 (tree) 구조를 이해해야 합니다.
function normalizeHeadings(tree: ContentTree): ContentTree {
let expectedLevel = 2; // PostAll이 생성한 콘텐츠는 H2부터 시작합니다 (H1은 페이지 제목임)
...
더 일찍 만들었어야 했던 것: 사람이 읽을 수 있는 에러를 제공하는 검증 레이어 (validation layer)
6개월 동안 검증 실패 (validation failures)는 일반적인 작업 오류 (generic job errors)로 나타났습니다. 작업이 실패하면 로그를 뒤져서 결국 ValidationError: heading_hierarchy_violated와 같은 내용을 찾아내야 했습니다. 전혀 도움이 되지 않았죠.
이제 모든 검증 실패는 구조화된 보고서 (structured report)를 반환합니다:
type ValidationReport = {
passed: boolean;
issues: Array<{
...
이는 또한 타겟팅된 지침 (targeted instructions)을 통한 자동 재시도 (automatic retry)를 위해 LLM 레이어로 다시 피드백됩니다. 예를 들어, 메타 설명 (meta description)이 너무 길 경우, 재시도 프롬프트에는 "이전 메타 설명은 184자였습니다. 160자 미만으로 다시 작성하세요."라는 내용이 포함됩니다. 이를 통해 2주 동안 포맷팅 관련 작업 실패율을 67% 감소시켰습니다.
CMS 커넥터 (CMS connectors)
기능
포맷팅된 콘텐츠는 어딘가로 전송되어야 합니다. PostAll은 현재 WordPress (REST API), Contentful, Webflow CMS, 그리고 범용 웹훅 (generic webhook) 타겟을 지원합니다. 각 커넥터는 PostAll의 내부 FormattedContent 타입을 대상 CMS가 기대하는 형식으로 변환하는 얇은 어댑터 (thin adapter) 역할을 합니다.
// connectors/wordpress.ts
export class WordPressConnector implements CMSConnector {
async publish(content: FormattedContent, config: WordPressConfig): Promise<PublishResult> {
...
각 커넥터는 자체적인 에러 매핑 (error mapping)을 처리합니다. Contentful에서 발생하는 409 에러 (슬러그 충돌, slug conflict)는 401 에러 (만료된 인증 토큰, expired auth token)와는 다른 복구 경로를 가집니다. CMSConnectorError 타입은 작업 시스템 (job system)이 재시도할지, 클라이언트에게 알릴지, 아니면 특정 에러 메시지를 노출할지를 결정할 수 있도록 충분한 컨텍스트 (context)를 제공합니다.
웹훅 타겟 (The webhook target)
제가 만든 가장 유용한 커넥터는 의외로 가장 단순한 것이었습니다. 바로 범용 웹훅 (generic webhook)입니다. 클라이언트가 지원되는 CMS 플랫폼 중 어느 것도 사용하지 않을 경우, PostAll에 URL을 제공하면 저희는 포맷팅된 콘텐츠를 JSON 형식으로 해당 URL에 POST 합니다. 전달 처리는 클라이언트 측에서 직접 수행합니다.
// connectors/webhook.ts
export class WebhookConnector implements CMSConnector {
async publish(content: FormattedContent, config: WebhookConfig): Promise<PublishResult> {
...
HMAC 서명 (HMAC signing)은 한 클라이언트가 "이 요청이 실제로 귀사에서 보낸 것인지 어떻게 알 수 있나요?"라고 질문한 이후에 추가한 기능입니다. 이제 모든 웹훅 전달은 클라이언트별 비밀키 (per-client secret)로 서명됩니다. 클라이언트 측의 검증 코드는 Python이나 Node로 단 네 줄이면 충분합니다. 처음부터 추가할 가치가 있는 기능이었습니다.
다이어그램으로 보는 아키텍처 (The architecture in one diagram)
단일 콘텐츠 작업(content job)을 위해 이 다섯 가지 레이어가 런타임(runtime) 시점에 어떻게 연결되는지는 다음과 같습니다:
Client Request
│
▼
...
각 박스는 자체적인 에러 처리(error handling) 및 재시도 예산(retry budget)을 가진 별도의 모듈입니다. 큐 시스템(queue system)은 작업 지속성(job persistence)을 인지하는 유일한 레이어이며, 그 아래의 모든 요소는 상태가 없는(stateless) 구조입니다.
내가 다르게 했을 부분
1. 포맷팅 엔진(formatting engine)을 가장 먼저 구축했을 것입니다.
LLM 레이어는 모델 자체가 흥미롭기 때문에 모든 관심을 받습니다. 하지만 포맷팅 엔
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기