Files
landing-manager/landing-strategy.md
2026-03-05 10:35:28 +09:00

21 KiB

랜딩 페이지 SaaS 전략 문서

작성일: 2026-03-01

1. 제품 방향

  • 대상: 광고 운영자가 고객 랜딩 페이지를 빠르게 생성·수정·배포·분석 가능한 SaaS
  • 핵심 가치: "빠른 제작 + 안정적인 발행 + 성과 측정 + 팀 협업"
  • 기술 스택(기준): Nuxt 4, Elysia, Prisma, SQLite(개발), PostgreSQL(운영), Bun, Nuxt UI, Pinia

2. 아키텍처 개요

  • Frontend: Nuxt 4 + Nuxt UI + Pinia
  • Backend: Elysia REST API
  • ORM: Prisma
  • DB: 개발 SQLite, 운영 PostgreSQL
  • 파일/정적 자산: Object Storage + CDN
  • 배포: Docker + CI/CD

3. 구현 우선순위(권장)

3.1 MVP (필수)

  • 사용자/조직 관리 (회원가입, 로그인, 역할 기반 접근)
  • 프로젝트/페이지 CRUD
  • 랜딩 페이지 빌더(섹션 단위 블록)
  • 초안/발행 상태 관리(드래프트/퍼블리시/아카이브)
  • 페이지 미리보기
  • 폼 제출 수집(기본 리드 저장)
  • 기본 분석 이벤트(조회/클릭/제출)
  • 템플릿 기반 시작 템플릿 제공

3.2 확장판

  • 버전 히스토리 + 롤백
  • 커스텀 도메인 연결
  • 캠페인별 KPI 대시보드
  • 자동화 알림(이메일/슬랙)
  • 결제/구독 모델(요금제)
  • 팀 협업(댓글, 승인 플로우)

4. 데이터/도메인 모델 핵심 엔티티

  • User: 인증/프로필
  • Team: 팀/고객사 단위 소유권
  • Project: 고객별 랜딩 프로젝트
  • LandingPage: 페이지 메타 정보
  • LandingPageVersion: 버전 히스토리
  • PublishLog: 발행 이력
  • FormSubmission: 랜딩 폼 제출
  • LeadEvent: 트래킹 이벤트
  • Subscription(선택): 요금제/사용량

5. 기능 상세 체크리스트

인증/권한

  • JWT 또는 세션 기반 인증
  • 역할: Owner/Admin/Editor/Viewer
  • 팀 단위 ACL + 프로젝트 단위 권한
  • 감사 로그 필수

멀티테넌시

  • team_id/project_id 기반 강제 분리
  • 리소스 조회 시 항상 소유권 조건 적용
  • 교차 접근 방지 테스트 필수

배포/발행

  • 페이지 상태: draft, scheduled, published, archived
  • 발행 동작은 큐 기반 비동기 처리 고려
  • 발행 로그/실패 사유 저장

SEO/마케팅 설정

  • 메타 태그(og,title,description)
  • 스크립트 태깅 (GA, 픽셀 등)
  • OG 이미지/파비콘/robots 설정

분석/리포트

  • 이벤트 모델 최소화: page_view, section_view, cta_click, form_submit
  • 지표 집계(일 단위)
  • 기간별 조회 및 CSV 내보내기

보안

  • 입력 유효성/인젝션 방지
  • rate limit, CORS, CSP, 보안 헤더
  • 파일 업로드 확장자/용량 정책

운영

  • 로그, 모니터링, 에러 추적(Sentry)
  • 백업/복구 절차 정리
  • 릴리스 전 Prisma migration 검사

6. SQLite → PostgreSQL 전환 전략

  1. Prisma schema를 PostgreSQL/SQLite 타입 호환 위주로 작성
  2. DB 특화 함수/타입(특히 JSON/날짜/enum) 사용 최소화
  3. prisma migrate 기반 migration 기록을 운영 분기와 동기화
  4. 전환 전:
    • 마이그레이션 dry-run
    • 샘플 데이터 이관 스크립트 작성
    • 무중단 전환 테스트

7. 권장 개발 규칙

  • TypeScript strict 사용
  • ESLint/Prettier 통일
  • API 계약 우선 (DTO, zod/valibot 등 검증)
  • 페이지 빌더 스키마 버전 관리
  • CI에서 테스트(요청 시 실행)

8. 다음 액션(요청하면 바로 진행)

  • Prisma 스키마 초안 작성
  • DB ERD/시퀀스 다이어그램 정리
  • MVP API 엔드포인트 목록 도출
  • 실제 폴더 구조 제안

9. 최소 라우트/페이지 설계(1차)

9.1 공개 라우트(인증 불필요)

  • / 또는 /{path?}: 도메인 기반 랜딩 라우팅 진입점
    • 동작: 요청 호스트 + path 기준으로 매핑된 랜딩 조회 후 렌더
    • 조건 엔진 적용: 요일/시간/기간 규칙을 먼저 검사해 매칭 페이지 렌더
  • /_lead/success: 폼 제출 성공 화면(선택)
  • /_lead/error: 폼 제출 실패 화면(선택)

9.2 운영자 라우트(인증 필요)

  • /login: 로그인
  • /admin (Dashboard): 캠페인·리드 개요
  • /admin/campaigns: 캠페인 목록
  • /admin/campaigns/new: 캠페인 생성
  • /admin/campaigns/[id]/edit: 캠페인 편집
  • /admin/pages: 랜딩 페이지 목록(캠페인 필터)
  • /admin/pages/new: 랜딩 페이지 생성
  • /admin/pages/[id]: 랜딩 상세/조회
  • /admin/pages/[id]/builder: 블록형 빌더 편집
    • 하위 탭: 콘텐츠 / 블록 / 도메인 라우팅 / 조건 규칙 / 미리보기
  • /admin/pages/[id]/conditions: 조건 규칙 전용(요청 시 별도 탭으로 분리 가능)
  • /admin/pages/[id]/routes: 도메인+경로 매핑 관리
  • /admin/leads: 리드 조회 목록
  • /admin/leads/[campaignId]: 캠페인별 리드 목록
  • /admin/users: 사용자/역할 관리(관리자, 리드관리자)

9.3 API 엔드포인트 권장(요청 도메인 라우팅/조건 렌더링 중심)

  • GET /api/public/route-by-host: 호스트+path 기반 랜딩 조회
  • POST /api/public/lead: 리드 생성(폼 제출)
  • GET /api/admin/campaigns
  • POST /api/admin/campaigns
  • GET /api/admin/pages
  • POST /api/admin/pages
  • GET/PUT /api/admin/pages/{id}/builder
  • GET/POST /api/admin/pages/{id}/conditions
  • POST /api/admin/pages/{id}/routes
  • GET /api/admin/leads
  • GET /api/admin/users

9.4 1차 최소 화면(권장)

  • 로그인
  • 관리자 대시보드
  • 캠페인 목록/생성/수정
  • 랜딩 목록/빌더
  • 조건 규칙 편집(요일/시간 슬롯)
  • 도메인 라우팅 편집
  • 리드 목록 조회
  • 사용자/역할 관리(선택)

9.5 1차 제외(유지보수/확장으로 미루기)

  • 버전관리/롤백
  • 예약 발행
  • 내부 트래픽 분석 대시보드
  • 결제/구독
  • 다국어/SEO 고도화

10. 1차 핵심 데이터 모델 설계(시작안)

10.1 엔티티 개요

  • Campaign: 종목(광고 그룹)
    • 한 종목 아래 여러 랜딩 페이지 보유
  • LandingPage: 실제 렌더링 대상 페이지
    • 블록 데이터 저장(이미지/폼/버튼 등)
  • LandingRoute: 요청 도메인+경로의 기본 매핑
    • 예: aaa.com + /, aaa.com/google
  • RouteCondition: 특정 시간/요일 규칙 매칭 시 교체할 페이지 지정
  • Lead: 폼 제출 데이터
  • User: 관리자/리드관리자 계정

10.2 엔티티 관계

  • Campaign 1:N LandingPage
  • Campaign 1:N LandingRoute
  • LandingRoute 1:N RouteCondition
  • LandingRoute N:1 LandingPage (기본 랜딩)
  • RouteCondition N:1 LandingPage (조건 매칭 시 보여줄 랜딩)
  • LandingPage 1:N Lead
  • Campaign 1:N Lead

10.3 권한 설계(최소)

  • ADMIN
    • 모든 관리자 화면 접근
    • 캠페인/페이지/조건/도메인/사용자 관리
  • LEAD_MANAGER
    • 리드 목록 조회만 가능

10.4 최소 Prisma 스키마 초안(개념용)

enum UserRole {
  ADMIN
  LEAD_MANAGER
}

model Campaign {
  id          String   @id @default(cuid())
  name        String
  description String?
  isActive    Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  landingPages LandingPage[]
  routes      LandingRoute[]
  leads       Lead[]
}

model LandingPage {
  id          String   @id @default(cuid())
  campaignId  String
  campaign    Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  name        String
  slug        String?
  blocks      Json     // 블록형 에디터 JSON
  isDefault   Boolean  @default(false)
  isActive    Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  routes      LandingRoute[] @relation("RouteDefaultPage")
  conditions  RouteCondition[] 
  leads       Lead[] 

  @@index([campaignId, isActive])
}

model LandingRoute {
  id             String   @id @default(cuid())
  campaignId     String
  campaign       Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  host           String   // 예: aaa.com
  path           String   // 예: /, /google
  defaultPageId  String
  defaultPage    LandingPage @relation("RouteDefaultPage", fields: [defaultPageId], references: [id], onDelete: Restrict)
  isActive       Boolean  @default(true)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  conditions     RouteCondition[]
  leads          Lead[]

  @@unique([host, path])
}

model RouteCondition {
  id          String        @id @default(cuid())
  routeId     String
  route       LandingRoute  @relation(fields: [routeId], references: [id], onDelete: Cascade)
  pageId      String
  page        LandingPage   @relation(fields: [pageId], references: [id], onDelete: Restrict)
  label       String        // 운영자 확인용
  priority    Int           @default(0) // 높을수록 우선
  isActive    Boolean       @default(true)

  startDate   DateTime?
  endDate     DateTime?
  timezone    String?       @default("Asia/Seoul")

  // 7자리 문자열: 0(sun)~6(sat) 1=적용
  weekMask    String        @default("0000000")

  // 분 단위(0~1439)
  startMinute Int?
  endMinute   Int?

  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
}

model Lead {
  id            String    @id @default(cuid())
  campaignId    String
  campaign      Campaign  @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  pageId        String
  page          LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
  routeId       String?
  route         LandingRoute? @relation(fields: [routeId], references: [id], onDelete: SetNull)
  payload       Json      // 폼 데이터
  sourceMeta    Json?     // ip, userAgent, referer 등
  submittedAt   DateTime  @default(now())
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String   // bcrypt 해시
  role      UserRole @default(ADMIN)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

10.5 조건 평가 규칙(우선순위)

  1. 요청 호스트+path로 LandingRoute 조회
  2. 해당 라우트의 RouteConditionisActive = true만 대상
  3. 현재 day/time/date가 조건(weekMask, startMinute~endMinute, startDate~endDate)에 일치하면 후보로 포함
  4. 후보가 여러 개면 priority desc, createdAt desc로 정렬해 첫 번째 사용
  5. 후보가 없으면 defaultPageId 페이지 렌더

10.6 렌더 우선순위 및 폴백

  • 매칭은 exact 방식(요청 path와 저장된 path가 완전 일치)으로 수행
  • 라우트 미존재: 404 또는 안내 페이지
  • startMinute/endMinute가 교차 구간(예: 20:00~06:00)인 경우 자정 넘김 처리

10.7 버튼 블록 설정(카카오 포함)

  • 공통: buttonType, label, style, url, target
  • 카카오 버튼:
    • kakaoClientId
    • kakaoSyncCode 또는 isKakaoSyncCodeInput
    • redirectUri
    • 필요 시 스크립트 코드 블록
  • 버튼 설정은 페이지 JSON 블록 단위로 저장

10.8 빌더 최소 블록 타입

  • hero(문구/배경)
  • image_gallery(이미지 나열)
  • form(입력 폼)
  • button(일반/카카오 버튼)
  • footer

11. 생성 규칙(공식 방식 고정)

11.1 원칙

  • Nuxt 4, Elysia, Prisma, Bun의 프로젝트 생성/설정은 공식 문서/공식 CLI 기준으로만 수행
  • 임의 경로/구조를 새로 만들지 않고, 공식 권장 생성물을 우선 사용
  • 추후 코드 생성은 공식 패턴의 레이어를 유지하며, 기존 공식 파일 위에 기능만 추가

11.2 프로젝트 초기화 규칙(요약)

  • Nuxt는 공식 nuxi 초기화/레이아웃/라우트 규칙 준수
  • Elysia는 공식 앱 생성 및 플러그인 미들웨어 등록 순서 준수
  • Prisma는 backend 기준 prisma init + backend/prisma/schema.prisma + migrate 기준
  • Bun은 공식 실행 스크립트/패키지 실행 규칙 준수

11.3 다음 작업 시 준수할 실행 순서(예정)

  1. Nuxt 공식 생성 및 폴더 구조 확보
  2. Elysia 공식 방식으로 API 엔트리 포인트 생성
  3. Prisma 스키마를 공식 스키마 문법으로 정식화
  4. Pinia 공식 store 패턴으로 상태영역 분리
  5. 위 규칙에 맞는 페이지/라우트 파일만 추가

12. 네트워크/운영 결정 반영(최종 합의안)

12.1 환경 기본값

  • 로컬 DB: dev.db (SQLite)
  • 프론트엔드 포트: 3000
  • API 포트: 4000
  • 기본 타임존: Asia/Seoul

12.2 블록 편집 정책

  • 블록은 드래그 앤 드롭으로 순서 변경 지원
  • 핵심 블록 타입: 이미지 기반 섹션, 폼, 버튼, 푸터
  • 카카오 버튼은 블록 단위 설정값으로 kakaoSyncCode(카카오 싱크 고유값) 입력 지원

12.3 권한 정책

  • 역할: ADMIN, LEAD_MANAGER 2개로 확정
  • ADMIN: 모든 관리 기능
  • LEAD_MANAGER: 리드 조회만

12.4 도메인/경로 규칙

  • 기본 도메인 예시: aaa.com 사용
  • 경로 규칙은 현재 도메인+패스 매칭으로 처리하되, /(슬래시 단독 루트) 경로 사용
  • 경로 충돌 방지를 위해 aaa.com 자체와 / 매핑을 동일한 기본 기준으로 운용

12.5 exact와 prefix의 의미(확인용)

  • exact 매칭: 요청 경로가 저장된 경로와 완전히 같아야 매칭
    • 예: 저장 경로가 /google이면 요청도 정확히 /google일 때만 매칭
  • prefix 매칭: 저장된 경로가 요청 경로의 시작 부분이면 매칭
    • 예: 저장 경로가 /이면 /google, /landing 모두 매칭(루트가 모든 경로 포괄)
  • 현재 1차는 exact로 고정

13. 1차 정식 Prisma 스키마(실행형 초안)

SQLite 기준(예: backend/prisma/schema.prisma)으로 작성. PostgreSQL 전환 시에는 provider = "postgresql"로 교체.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

enum UserRole {
  ADMIN
  LEAD_MANAGER
}

model Campaign {
  id          String         @id @default(cuid())
  name        String
  description String?
  isActive    Boolean        @default(true)
  createdAt   DateTime       @default(now())
  updatedAt   DateTime       @updatedAt

  landingPages LandingPage[]
  routes      LandingRoute[]
  leads       Lead[]
}

model LandingPage {
  id           String           @id @default(cuid())
  campaignId   String
  campaign     Campaign         @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  name         String
  slug         String?
  blocks       Json             // 블록형 에디터 데이터(배열)
  isDefault    Boolean          @default(false)
  isActive     Boolean          @default(true)
  createdAt    DateTime         @default(now())
  updatedAt    DateTime         @updatedAt

  defaultRoutes LandingRoute[] @relation("RouteDefaultPage")
  conditions    RouteCondition[]
  leads         Lead[]

  @@index([campaignId, isActive])
}

model LandingRoute {
  id            String        @id @default(cuid())
  campaignId    String
  campaign      Campaign      @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  host          String        // ex) aaa.com
  path          String        // ex) /, /google
  defaultPageId String
  defaultPage   LandingPage   @relation("RouteDefaultPage", fields: [defaultPageId], references: [id], onDelete: Restrict)
  isActive      Boolean       @default(true)
  createdAt     DateTime      @default(now())
  updatedAt     DateTime      @updatedAt

  conditions    RouteCondition[] 
  leads         Lead[]

  @@unique([host, path])
}

model RouteCondition {
  id          String         @id @default(cuid())
  routeId     String
  route       LandingRoute   @relation(fields: [routeId], references: [id], onDelete: Cascade)
  pageId      String
  page        LandingPage    @relation(fields: [pageId], references: [id], onDelete: Restrict)
  label       String
  priority    Int            @default(0) // 높을수록 우선
  isActive    Boolean        @default(true)

  startDate   DateTime?
  endDate     DateTime?
  timezone    String         @default("Asia/Seoul")

  // 7자 문자열(일~토) ex) 0111100
  weekMask    String         @default("0000000")

  startMinute Int?
  endMinute   Int?

  createdAt   DateTime       @default(now())
  updatedAt   DateTime       @updatedAt
}

model Lead {
  id          String      @id @default(cuid())
  campaignId  String
  campaign    Campaign    @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  pageId      String
  page        LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
  routeId     String?
  route       LandingRoute? @relation(fields: [routeId], references: [id], onDelete: SetNull)
  payload     Json        // 폼 submit payload
  sourceMeta  Json?
  submittedAt DateTime    @default(now())

  @@index([campaignId, submittedAt])
}

model User {
  id       String   @id @default(cuid())
  email    String   @unique
  name     String?
  password String
  role     UserRole @default(ADMIN)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

주의:

  • 위 스키마는 개념 정리 목적이며, SQLite에서 지원되지 않는 제약/체크 조건은 실제 마이그레이션 환경에서 조정 필요.
  • RouteRelationDefaultPage와 같은 별도 보조 모델은 사용하지 않음.

14. 라우팅/조건 매칭 알고리즘(1차: exact)

14.1 목적

  • 요청 host/path에 대해 기본 페이지를 찾아서, 조건 규칙 매칭이 있으면 변형 페이지로 전환.

14.2 pseudocode

// Elysia 핸들러에서 사용
function isWeekMatch(weekMask: string, now: Date, timezone: string): boolean {
  const tzNow = new Date(now.toLocaleString("en-US", { timeZone: timezone }))
  const day = tzNow.getDay() // 0=Sun..6=Sat
  return weekMask[day] === "1"
}

function inTimeRange(startMinute?: number | null, endMinute?: number | null, nowMinute: number): boolean {
  if (startMinute == null || endMinute == null) return true // no time restriction
  if (startMinute <= endMinute) {
    return nowMinute >= startMinute && nowMinute <= endMinute
  }
  // cross midnight: 예) 20:00~06:00
  return nowMinute >= startMinute || nowMinute <= endMinute
}

function inDateRange(startDate: Date | null, endDate: Date | null, now: Date): boolean {
  if (startDate && now < startDate) return false
  if (endDate && now > endDate) return false
  return true
}

async function resolveLandingPage(host: string, path: string) {
  const route = await prisma.landingRoute.findFirst({
    where: { host, path, isActive: true },
    include: { defaultPage: true, conditions: true }
  })
  if (!route) return null

  const now = new Date()
  const nowMinute = now.getHours() * 60 + now.getMinutes()

  const matched = route.conditions
    .filter((c) => c.isActive)
    .filter((c) => inDateRange(c.startDate, c.endDate, now))
    .filter((c) => isWeekMatch(c.weekMask, now, c.timezone ?? "Asia/Seoul"))
    .filter((c) => inTimeRange(c.startMinute, c.endMinute, nowMinute))
    .sort((a, b) => b.priority - a.priority || +new Date(b.updatedAt) - +new Date(a.updatedAt))

  return matched.length > 0 ? matched[0].pageId : route.defaultPageId
}

14.3 라우트 우선순위

  • 1단계: exact host+path 조회 (AAA.com, /google 정확히 일치)
  • 2단계: 조건 일치 페이지 선택(우선순위/최신 업데이트 순)
  • 3단계: 일치 조건 없으면 defaultPageId

15. 1차 API 계약(구현 시작점)

15.1 공개 API

  • GET /api/public/route-by-host
    • Query: host, path
    • Response:
      • page: blocks 포함 렌더 데이터
      • campaignId, pageId, routeId
      • status: ok | not_found
  • POST /api/public/lead
    • Body: campaignId, pageId, routeId, payload, sourceMeta
    • Response: { ok: true }

15.2 관리자 API(필수)

  • POST /api/admin/auth/login
  • GET /api/admin/campaigns, POST /api/admin/campaigns
  • GET /api/admin/pages, POST /api/admin/pages, GET /api/admin/pages/:id, PUT /api/admin/pages/:id
  • GET /api/admin/pages/:id/route, PUT /api/admin/pages/:id/route
  • GET /api/admin/pages/:id/conditions, POST /api/admin/pages/:id/conditions, PATCH /api/admin/pages/:id/conditions/:conditionId
  • GET /api/admin/leads
  • GET /api/admin/users, POST /api/admin/users, PUT /api/admin/users/:id/role

15.3 공통 응답 규칙(제안)

  • 인증 필요: { error: "unauthorized" }
  • 권한 부족: { error: "forbidden" }
  • 검증 실패: { error: "validation", details: [...] }

16. 문서 분리(운영 모드)

  • 전략/결정: docs/strategy.md
  • DB/스키마: docs/database.md
  • 라우팅/조건 엔진: docs/routing-conditions.md
  • 화면/라우팅: docs/ui-flow.md
  • API 계약: docs/api-spec.md
  • 실행 코드(초안):
    • Prisma 스키마: backend/prisma/schema.prisma
    • 조건 라우팅 엔진: backend/src/routing/resolveLandingPage.ts

향후 구현을 진행할 때는 위 문서를 기준으로 업데이트하고, 필요 시 landing-strategy.md는 요약본으로 유지한다.