나이지리아 중소기업(SMBs)을 위한 다중 페이지 AI 웹사이트 생성기 구축기 — 아키텍처, LLM 프롬프팅(Prompting) 및 교훈
요약
나이지리아 소상공인을 위해 AI를 활용하여 다중 페이지 웹사이트를 자동 생성하는 WebDigitize의 구축 과정을 다룹니다. Next.js, FastAPI, Anthropic Claude API를 결합하여 비즈니스 정보를 기반으로 맞춤형 웹사이트 JSON 구조를 생성하는 아키텍처를 설명합니다.
핵심 포인트
- Claude API를 활용해 디자인 블록 구조인 Puck JSON 문서를 생성함
- Next.js 15와 FastAPI를 이용한 비동기 생성 파이프라인 구축
- 사용자 입력(비즈니스 브리프)을 디자인 토큰과 매핑하여 일관성 유지
- 현지 시장(나이지리아)의 결제 및 도메인 문제를 해결하는 종단간 솔루션
문제점
대부분의 나이지리아 소상공인들은 웹상에 존재감이 전혀 없습니다. 웹사이트를 갖게 되더라도, 보통 프리랜서가 3주 동안 작업하여 150,000 ₦라는 감당하기 어려운 비용을 청구한 채 방치된 브로슈어 형태의 페이지인 경우가 많습니다. 프리랜서는 이미 떠났고, 사업주는 단 한 단어도 직접 수정할 수 없습니다.
Wix, Squarespace, GoDaddy와 같은 기존의 "웹사이트 빌더 (website builder)" 대안들은 나이지리아를 위해 만들어지지 않았습니다. 결제 통합은 Stripe(나이지리아 상인에게는 직접 제공되지 않음)를 의미합니다. 도메인 등록은 기본적으로 USD(미국 달러)로 설정됩니다. 템플릿은 마이두구리(Maiduguri)가 아닌 맨체스터(Manchester)에 어울리는 것처럼 보입니다. 인터페이스가 완벽하더라도, 라고스(Lagos)의 제과점 주인은 전문적인 웹사이트를 갖기 위해 히어로 섹션(hero section)과 콜 투 액션(call-to-action) 버튼의 차이를 이해할 필요가 없어야 합니다.
저는 이 문제를 종단간(end-to-end)으로 해결하기 위해 WebDigitize를 구축했습니다. 나이지리아 사업주가 짧은 온보딩(onboarding) 양식(비즈니스 이름, 유형, 간단한 설명, 선호하는 시각적 스타일, 전화번호, 도시)을 작성하면, 몇 분 안에 라이브 서브도메인(subdomain)과 이커머스(e-commerce) 기능이 포함된 완성된 다중 페이지 스타일 웹사이트를 받는 플랫폼입니다.
어려운 부분은 첫 번째 단계입니다. 즉, 이 다섯 가지 필드를 가져와서 마치 사람이 디자인한 것처럼 보이는 웹사이트를 제작하는 것입니다.
아키텍처 개요
Next.js 15 (App Router) ←→ FastAPI (Python) ←→ PostgreSQL (Neon)
↓
Anthropic Claude API
...
핵심 웹사이트 빌더는 Puck — 오픈 소스 React 드래그 앤 드롭 에디터입니다. Puck은 페이지 콘텐츠를 타입화된 블록(HeroSection, FeaturesGrid, TestimonialCard 등)의 트리 구조를 설명하는 JSON 문서로 저장합니다. AI의 역할은 특정 비즈니스에 적합한 콘텐츠로 채워진 해당 JSON 문서를 생성하는 것입니다.
생성 파이프라인 (Generation Pipeline)
1단계: 비즈니스 브리프 (Business Brief)
온보딩 마법사는 다음을 수집합니다:
interface BusinessBrief {
businessName: string;
businessType: string; // "Restaurant", "Law firm", "Tech startup" 등
...
style 필드는 구체적인 디자인 토큰 세트(font pairings, border radii, colour palette)에 매핑되므로, AI가 헥스 코드(hex codes)에 대해 추론할 필요가 없습니다. AI는 명명된 네 가지 페르소나(personalities) 중에서 선택하며, 나머지는 플랫폼이 채웁니다.
2단계: 홈 페이지 생성 (항상 비동기 방식)
생성은 FastAPI 백그라운드 태스크(background task)에서 수행되므로, HTTP 응답이 즉시 반환되어 사용자는 진행 상황 화면을 볼 수 있습니다:
@router.post("/onboarding")
async def complete_onboarding(
body: OnboardingRequest,
...
생성 함수는 정교하게 구조화된 시스템 프롬프트(system prompt)와 함께 Claude를 호출하고 Puck JSON 문서를 반환합니다:
async def generate_home_page(brief: BusinessBrief) -> dict:
system = """
You are a professional web designer generating page content for Nigerian businesses.
...
"""
3단계: 디자인 비평 단계 (The Design Critic Pass)
LLM의 가공되지 않은 출력물은 기술적으로는 유효한 JSON일 수 있지만, 내용이 부실할 수 있습니다. 예를 들어 너무 일반적인 헤드라인, 비즈니스 유형과 맞지 않는 서비스, 명백히 가짜처럼 느껴지는 고객 후기 등이 이에 해당합니다. 첫 번째 생성 단계를 거친 후, 두 번째 프롬프트가 비평가(critic) 역할을 수행합니다:
async def critic_review(brief: BusinessBrief, draft: dict) -> dict:
critique_prompt = f"""
Review this website draft for {brief.business_name} ({brief.business_type}).
...
"""
이러한 2단계 접근 방식(two-pass approach)은 페이지당 한 번의 추가 API 호출 비용이 발생하지만, 단일 롱 프롬프트(single long prompt)를 사용하는 것보다 일관되게 눈에 띄게 더 나은 결과물을 생성합니다.
4단계: 하위 페이지 생성 (유료 플랜)
Growth 및 Pro 플랜 고객은 완전한 다중 페이지 사이트를 제공받습니다. 홈 페이지가 저장된 후, 추가 페이지들이 병렬로 생성됩니다:
async def generate_subpages(site_id: int, brief: BusinessBrief):
pages_to_generate = ["about", "services", "contact"]
tasks = [generate_page(brief, page) for page in pages_to_generate]
...
각 페이지 유형은 고유한 프롬프트(prompt)를 가지지만, 동일한 비평 단계(critic pass)를 공유합니다. 예를 들어, about 페이지 프롬프트는 일반적인 "회사 소개" 템플릿이 아니라, 비즈니스 설명에서 암시된 창업 스토리(origin story)를 명시적으로 요청합니다.
5단계: 상점 페이지 (결정론적, AI 미사용)
이커머스 상점 페이지는 데이터베이스의 구조화된 제품 데이터로부터 완전히 렌더링되며, LLM이 관여하지 않습니다. 이는 의도적인 결정이었습니다. 상점 레이아웃은 완벽하게 예측 가능해야 하고, 필터링과 페이지네이션(pagination)을 지원해야 하며, 실시간 재고에 반응해야 합니다. 로직(logic)을 실행해야 하는 페이지를 생성하도록 LLM에게 요청하는 것은 자폭 행위(footgun)와 같습니다.
대신, 상점은 AI가 무엇을 생성했는지와 관계없이 항상 지정된 위치에 렌더링되는 고정된 Puck 호환 레이아웃 컴포넌트(ShopPage)입니다. 이 블록은 siteId 프롭(prop)을 전달받아 클라이언트 측에서 실시간 제품 데이터를 가져옵니다.
Puck JSON 스키마 문제
이 프로젝트에서 가장 어려웠던 부분은 프롬프팅(prompting)이 아니라 스키마(schema) 설계였습니다.
Puck의 콘텐츠 형식은 다음과 같습니다:
{
"root": { "props": {} },
"content": [
...
각 블록 유형은 고유한 props 형태를 가집니다. LLM은 유효한 프롭 키(key)와 값 유형(value type)을 생성해야 합니다. 만약 컴포넌트 정의에 존재하지 않는 키를 환각(hallucinate)하여 생성하면, 블록은 아무런 오류 메시지 없이 렌더링에 실패합니다.
저의 해결책은 시스템 프롬프트(system prompt)에 압축된 블록 스키마를 포함하는 것이었습니다:
블록 스키마 (엄격함 — 오직 이 키들만 사용):
HeroSection:
...
스키마를 사용자 메시지(user message)가 아닌 시스템 프롬프트에 유지함으로써, 다회차 비평 단계(multi-turn critic passes) 동안에도 이를 고정할 수 있었습니다. 처음에는 사용자 메시지에 넣었더니, 비평 단계에서 가끔 제약 사항을 "망각"하고 프롭을 환각하는 일이 발생했습니다.
유효하지 않은 JSON 처리
Claude는 지시를 받았을 때 유효한 JSON을 생성하는 데 매우 신뢰할 만하지만, 저는 여전히 모든 파싱(parse) 과정을 지수 백오프(exponential backoff)를 적용한 재시도 루프(retry loop)로 감싸서 처리합니다:
async def generate_with_retry(brief, page_type, max_attempts=3):
for attempt in range(max_attempts):
try:
...
```
```$', '', raw.strip(), flags=re.MULTILINE)
return json.loads(cleaned)
except json.JSONDecodeError:
if attempt == max_attempts - 1:
...
```
마크다운 펜스(markdown fence) 제거는 가장 흔한 실패 모드를 처리합니다. Claude는 JSON을 사용하지 말라고 지시받았음에도 불구하고 가끔 `json` 코드 블록으로 감싸는 경우가 있기 때문입니다.
## 이미지 소싱 (Image Sourcing)
생성된 페이지는 URL이 아닌 의미론적 쿼리(semantic query)를 통해 이미지를 참조합니다. 콘텐츠 생성 후, 두 번째 비동기(async) 단계에서 자동으로 도출된 검색어를 사용하여 Stock Photos API에 쿼리를 보냅니다:
```
async def hydrate_images(site: Site, page_content: dict) -> dict:
"""의미론적 이미지 플레이스홀더(placeholder)를 실제 Stock Photo API의 URL로 교체합니다."""
for block in page_content.get("content", []):
...
```
LLM은 URL 대신 `backgroundImageQuery: "nigerian bakery fresh bread"`를 출력합니다. 하이드레이션(hydration) 단계는 검증(validation) 후에 실행되므로, 이미지 가져오기에 실패하더라도 페이지 저장 과정이 중단되지 않습니다.
## 나이지리아 특화 디자인 결정 사항
### 결제: Stripe가 아닌 Paystack
Paystack은 나이지리아 카드 결제의 사실상 표준(de facto standard)입니다. 통합 범위는 다음과 같습니다:
- Paystack 대시보드에서 관리되는 플랜 코드가 포함된 월간 및 연간 결제 플랜
- 웹훅(Webhook) 기반의 구독 상태 머신 (charge.success → 활성화, subscription.not_renew → 비활성화)
- 전 과정에 걸친 나이라(Naira) 금액 사용 — 통화 변환의 복잡성 없음
연간 결제를 구현하려면 마케팅 가격 페이지의 `interval` 선택 사항을 가입 → 온보딩(onboarding) → 백엔드(backend) → Paystack 플랜 코드 선택까지 모두 전달해야 했습니다. 핵심적인 아키텍처 포인트는 `interval`을 Paystack 웹훅 메타데이터에 저장하는 것입니다. 이를 통해 사용자가 결제를 시작한 시점과 완료한 시점 사이에 브라우저가 충돌하더라도 올바른 플랜이 활성화될 수 있습니다.
### 커스텀 도메인: Cloudflare for SaaS
모든 사이트는 무료 서브도메인(`yourstore.webdigitize.com`)을 할당받습니다. 유료 플랜 사용자는 커스텀 도메인(Custom Domain)을 연결할 수 있습니다. 이는 Cloudflare for SaaS를 통해 작동합니다. 각 커스텀 도메인은 Cloudflare Custom Hostname으로 등록되며, 이를 통해 당사 측에서 사이트별로 별도의 DNS 설정을 할 필요 없이 SSL 프로비저닝(Provisioning)과 CNAME 라우팅(Routing)을 처리합니다.
Next.js 미들웨어(Middleware)는 들어오는 호스트네임(Hostname)을 읽어 올바른 사이트로 라우팅하고, 해당 사이트의 테마 토큰(Theme tokens)과 콘텐츠를 주입합니다. 단 한 번의 배포로 수천 개의 상점을 서비스할 수 있습니다.
### 기본 CTA로서의 WhatsApp
대부분의 나이지리아 중소기업(SMBs)에게 WhatsApp은 이메일이나 문의 양식이 아닌, 주요 비즈니스 커뮤니케이션 채널입니다. AI 생성 파이프라인(Pipeline)은 전화번호가 사용 가능한 경우, 기본 CTA(Call to Action)를 WhatsApp 딥링크(`https://wa.me/{phone}`)로 설정하도록 지시받습니다. 이것만으로도 고객을 위해 생성된 사이트의 전환율(Conversion rate)을 유의미하게 높일 수 있습니다.
## 내가 실수했던 점 (그리고 해결한 점)
**1. AI로 쇼핑 페이지를 생성하려고 시도한 것**
첫 번째 반복(Iteration)에서는 Claude에게 쇼핑 페이지 레이아웃을 생성하도록 요청했습니다. 결과물은 정적으로는 괜찮아 보였지만, 제품 데이터, 장바구니 통합, 실시간 재고가 없는 '죽은' 페이지였습니다. 일부 페이지는 콘텐츠가 아닌 코드가 필요하다는 사실을 받아들이기 전까지 일주일이라는 시간을 허비했습니다.
**2. 단일 프롬프트에 너무 많은 내용을 담은 것**
첫 세대의 홈 페이지 프롬프트는 2,000토큰(Token) 분량의 지시 블록을 사용하여 한 번에 6개 섹션을 모두 생성하려고 시도했습니다. 출력 품질은 일관되지 않았습니다. 이를 '생성 패스(Generation pass)'와 짧은 프롬프트를 사용한 '집중 비평 패스(Focused critic pass)'로 나누자, 프롬프트 엔지니어링(Prompt engineering) 노력을 덜 들이고도 훨씬 더 나은 결과를 얻을 수 있었습니다.
**3. 스키마 계층에서 `props`를 고정하지 않은 것**
초기 컴포넌트들은 유연한 `props`를 가졌습니다. 즉, 어떤 키(key)든 허용되었고, 누락된 키는 기본값(defaults)으로 대체되었습니다. 이는 LLM의 작업을 더 쉽게 만드는 것처럼 느껴졌지만, 검증 오류(validation errors)를 렌더링 시점(render time)으로 미루게 만들었고, 그곳에서 오류는 소리 없이 지나갔습니다. 스키마 경계(schema boundary)에서 런타임 검증(runtime validation)을 포함한 엄격한 TypeScript 프롭 타입(prop types)으로 전환함으로써, 환각(hallucinated)된 키를 즉시 잡아낼 수 있었고 개발 과정에서 더 깔끔한 에러 메시지를 얻을 수 있었습니다.
**4. HTTP 응답을 차단하는 동기식 생성 (Synchronous generation)**
첫 번째 버전은 요청 핸들러(request handler) 내부에서 생성 과정을 `await` 했습니다. 4페이지 분량의 사이트의 경우, 이는 15~25초의 HTTP 응답 시간을 의미했습니다. FastAPI의 `BackgroundTasks`가 이 문제를 해결했습니다. 응답은 작업 ID(job ID)와 함께 즉시 반환되고, 클라이언트는 `/status` 엔드포인트를 폴링(poll)하며, Server-Sent Event(SSE)가 "준비 완료" 신호를 푸시합니다. 사용자는 빈 스피너 대신 진행 애니메이션을 보게 됩니다.
## 성능 및 비용
- 평균 홈 페이지 생성 시간: **8~12초** (두 번의 Claude API 호출)
- 평균 전체 사이트 (4페이지): **20~35초** (하위 페이지 병렬 생성)
- 생성된 사이트당 Claude API 비용: 대규모 운영 시 **우리의 마진(margin) 범위 내에 충분히 들어옵니다.**
- 스톡 사진(Stock Photos) API: 무료 티어는 시간당 200개의 요청을 지원하며, 현재 물량에는 충분하고도 남습니다.
생성당 비용은 월간 구독 수익에 비해 무시할 수 있는 수준입니다.
## 에디터: Puck
사이트가 생성되면, 사용자는 대시보드에 내장된 Puck 드래그 앤 드롭(drag-and-drop) 에디터를 통해 직접 편집할 수 있습니다. AI가 생성하는 것과 동일한 JSON 스키마를 에디터가 사용하므로, 사람이 수정한 내용과 AI가 재생성한 결과물은 동일한 형식으로 생성되어 동일한 데이터베이스 컬럼에 저장됩니다.
이것이 핵심적인 아키텍처 측면의 승리입니다: **AI는 에디터로 결과물을 넘겨주는 별개의 시스템이 아닙니다.** AI는 사람이 계속해서 편집할 수 있는 데이터 구조를 위한 초기화 도구(initialiser)입니다. 생성 후 아무런 관여를 원하지 않는 사용자는 즉시 라이브 웹사이트를 갖게 됩니다. 커스터마이징을 원하는 사용자는 완전한 시각적 에디터를 사용할 수 있습니다.
## 미결 과제 및 향후 작업
- **점진적 재생성 (Incremental regeneration)**: 페이지의 나머지 부분은 건드리지 않고 "서비스 섹션만 다시 생성해줘"라고 사용자가 요청할 수 있도록 합니다.
- **음성 기반 브리프 (Voice-driven brief)**: 60초 분량의 오디오 설명을 녹음하고, 이를 Whisper로 전사(transcribe)하여 생성 브리프(generation brief)로 사용합니다. 이는 기술에 익숙하지 않은 사용자들에게 타이핑이라는 장벽마저 제거해 줍니다.
- **수락/거부된 출력물에 대한 미세 조정 (Fine-tuning on accepted/rejected outputs)**: 사용자가 생성된 사이트 중 어느 부분을 집중적으로 수정하는지(초기 품질이 낮다는 신호) 추적하고, 이를 학습 신호로 사용합니다.
- **다국어 생성 (Multilingual generation)**: 영어가 주 언어가 아닌 사용자를 위해 요루바어(Yoruba), 하우사어(Hausa), 이보어(Igbo) UI 및 콘텐츠 생성을 지원합니다.
## 결론 (Conclusion)
이 프로젝트를 구축하며 배운 점은, AI 제품 개발에서 어려운 부분은 모델 자체가 아닌 경우가 많다는 것입니다. 진짜 어려운 것은 모델의 출력물과 나머지 시스템 사이의 인터페이스, 즉 스키마 설계 (schema design), 검증 (validation), 우아한 성능 저하 (graceful degradation), 그리고 피할 수 없는 지연 시간 (latency) 주변의 사용자 경험 (user experience)입니다.
이중 패스 생성 (dual-pass generation) + 비평가 (critic) 아키텍처는 출력 품질을 개선하기 위해 제가 수행한 가장 영향력 있는 작업이었습니다. 시스템 프롬프트 내 스키마 삽입 (schema-in-system-prompt) 트릭이 그 뒤를 바짝 쫓고 있습니다.
만약 여러분이 이와 유사한 것을 구축하고 있다면, 저의 권장 사항은 다음과 같습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기