Skip to content
Published on

스타트업 기술 스택 선택 가이드 2025: 0→1 단계에서 시리즈 B까지 기술 의사결정

Authors

1. 기술 스택 철학

1.1 Boring Technology 원칙

Dan McKinley의 "Choose Boring Technology" 에세이는 스타트업 기술 선택의 바이블입니다. 핵심 주장은 이렇습니다:

혁신 토큰 (Innovation Tokens): 모든 팀에는 제한된 수의 "혁신 토큰"이 있습니다. 새롭고 검증되지 않은 기술을 채택할 때마다 토큰을 소비합니다. 비즈니스 자체가 이미 하나의 혁신이므로, 기술 스택에서까지 모험을 할 필요는 없습니다.

┌─────────────────────────────────────────────────────┐
│              혁신 토큰 분배 예시                        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  보유 혁신 토큰: 3개                                   │
│                                                     │
│  좋은 예:│  ├── 비즈니스 모델 (혁신) ........... 토큰 1개 사용    │
│  ├── PostgreSQL (지루한 기술) ....... 토큰 0개         │
│  ├── Next.js (검증됨) .............. 토큰 0개         │
│  ├── 독자적 ML 파이프라인 (혁신) .... 토큰 1개 사용    │
│  └── 남은 토큰: 1 (예비)│                                                     │
│  나쁜 예:│  ├── 비즈니스 모델 (혁신) ........... 토큰 1개 사용    │
│  ├── CockroachDB (새로움) .......... 토큰 1개 사용    │
│  ├── Bun Runtime (새로움) .......... 토큰 1개 사용    │
│  ├── 독자적 ML 파이프라인 (혁신) .... 토큰 부족!│  └── 남은 토큰: 0 (위험)│                                                     │
└─────────────────────────────────────────────────────┘

1.2 Monolith First

Martin Fowler가 주장한 "Monolith First" 원칙. 마이크로서비스로 시작하지 말고, 모놀리스로 시작하여 필요할 때 분리하세요.

왜 모놀리스가 먼저인가:

  • 서비스 경계를 미리 올바르게 나누기는 거의 불가능
  • 개발 속도가 빠름 (서비스 간 통신 오버헤드 없음)
  • 디버깅이 쉬움 (단일 프로세스)
  • 트랜잭션 관리가 간단 (분산 트랜잭션 불필요)
  • 배포가 단순 (하나의 아티팩트)
┌──────────────────────────────────────────────────┐
│        아키텍처 진화 경로                            │
│                                                  │
Stage 0: 모놀리스 (MVP)│  ├── 단일 Next.js 앱                              │
│  ├── 단일 DB (Supabase)│  └── 단일 배포 (Vercel)│           │                                       │
 (PMF 달성)Stage 1: 모듈러 모놀리스                           │
│  ├── 도메인별 모듈 분리                             │
│  ├── 내부 API 경계 정의                             │
│  └── DB 스키마 분리 시작                            │
│           │                                       │
 (10+, 트래픽 급증)Stage 2: 선택적 서비스 분리                         │
│  ├── 병목 서비스만 분리                              │
│  ├── 이벤트 기반 비동기 처리 도입                     │
│  └── 캐싱 레이어 추가                               │
│           │                                       │
 (30+, 글로벌 확장)Stage 3: MSA (필요한 경우에만)│  ├── 독립 배포 가능한 서비스들                        │
│  ├── 서비스 메시                                    │
│  └── 중앙 집중식 관찰 가능성                         │
└──────────────────────────────────────────────────┘

1.3 PaaS-First 전략

인프라 관리에 시간을 쓰지 마세요. PaaS(Platform as a Service)로 시작하고, 필요할 때 IaaS로 이동하세요.

접근 방식인프라 관리 시간유연성비용 (초기)비용 (성장)
PaaS (Vercel, Railway)최소낮음무료-50달러높아질 수 있음
Managed (AWS ECS, GKE)중간중간100-500달러중간
Self-managed (K8s on EC2)높음높음200-1000달러최적화 가능

핵심 원칙: 초기에는 개발 속도 > 비용 최적화. PMF를 찾기 전에 인프라 최적화에 시간을 쓰는 것은 시간 낭비입니다.


2. Stage 0: MVP (월 0-50달러)

2.1 추천 스택

┌───────────────────────────────────────────────┐
MVP 기술 스택 (2025)├───────────────────────────────────────────────┤
│                                               │
Frontend:  Next.js 15 (App Router)Styling:   Tailwind CSS + shadcn/ui          │
Backend:   Next.js API Routes / Server Actions│
Database:  Supabase (PostgreSQL)Auth:      Supabase Auth / NextAuth.jsStorage:   Supabase Storage / Cloudflare R2Deploy:    VercelAnalytics: PostHog (self-hosted) / PlausiblePayments:  StripeEmail:     ResendMonitoring: Vercel Analytics + Sentry│                                               │
│  총 월 비용: 0 ~ 50 USD  (Vercel Free + Supabase Free Tier)│                                               │
└───────────────────────────────────────────────┘

2.2 Next.js + Supabase 프로젝트 셋업

# 프로젝트 생성
npx create-next-app@latest my-startup --typescript --tailwind --app --src-dir
cd my-startup

# 핵심 의존성 설치
npm install @supabase/supabase-js @supabase/ssr
npm install stripe @stripe/stripe-js
npm install resend
npm install zod react-hook-form @hookform/resolvers

# UI 라이브러리
npx shadcn@latest init
npx shadcn@latest add button card input form dialog toast

# 개발 도구
npm install -D prettier eslint-config-prettier
npm install -D @types/node

2.3 Supabase 설정

// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Component에서는 set 불가 - 무시
          }
        },
      },
    }
  )
}

// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

2.4 인증 구현

// src/app/auth/login/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) {
      alert(error.message)
    } else {
      router.push('/dashboard')
      router.refresh()
    }
    setLoading(false)
  }

  const handleGoogleLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    })
  }

  return (
    <div className="flex min-h-screen items-center justify-center">
      <form onSubmit={handleLogin} className="w-full max-w-md space-y-4 p-8">
        <h1 className="text-2xl font-bold">로그인</h1>
        <input
          type="email"
          placeholder="이메일"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full rounded border p-2"
          required
        />
        <input
          type="password"
          placeholder="비밀번호"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full rounded border p-2"
          required
        />
        <button
          type="submit"
          disabled={loading}
          className="w-full rounded bg-blue-600 p-2 text-white"
        >
          {loading ? '로그인 중...' : '로그인'}
        </button>
        <button
          type="button"
          onClick={handleGoogleLogin}
          className="w-full rounded border p-2"
        >
          Google로 로그인
        </button>
      </form>
    </div>
  )
}

2.5 Stripe 결제 연동

// src/app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { priceId } = await req.json()

  const session = await stripe.checkout.sessions.create({
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${req.nextUrl.origin}/dashboard?success=true`,
    cancel_url: `${req.nextUrl.origin}/pricing?canceled=true`,
    metadata: {
      userId: user.id,
    },
  })

  return NextResponse.json({ url: session.url })
}

// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.metadata?.userId

      if (userId) {
        await supabaseAdmin
          .from('subscriptions')
          .upsert({
            user_id: userId,
            stripe_customer_id: session.customer as string,
            stripe_subscription_id: session.subscription as string,
            status: 'active',
            plan: 'pro',
          })
      }
      break
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      await supabaseAdmin
        .from('subscriptions')
        .update({ status: 'canceled' })
        .eq('stripe_subscription_id', subscription.id)
      break
    }
  }

  return NextResponse.json({ received: true })
}

2.6 MVP 비용 분석

서비스무료 티어유료 전환 시점비용
Vercel100GB 대역폭, 무제한 배포트래픽 증가 시Pro: 20달러/월
Supabase500MB DB, 1GB 스토리지사용량 증가 시Pro: 25달러/월
Stripe사용량 기반매출 발생 시2.9% + 30센트/건
Resend100통/일전환율 테스트 시20달러/월
Sentry5K 이벤트/월에러 증가 시26달러/월
PostHog1M 이벤트/월분석 고도화 시0달러 (셀프 호스트)

총 MVP 비용: 0-50달러/월 (유료 전환 전까지 거의 무료)


3. Stage 1: Product-Market Fit (월 200-500달러)

3.1 PMF 단계의 기술 변화

PMF를 찾은 후, 다음 영역에서 업그레이드가 필요합니다:

┌───────────────────────────────────────────────┐
Stage 1 기술 스택 업그레이드               │
├───────────────────────────────────────────────┤
│                                               │
Database:   Supabase Pro → 더 큰 인스턴스      │
Cache:      + Redis (Upstash)Queue:      + BullMQ (Redis 기반)Search:     + Meilisearch / TypesenseCDN:        + CloudflareMonitoring: + Better Stack / Grafana CloudCI/CD:      GitHub Actions 고도화             │
Error:      Sentry Pro│                                               │
│  월 비용: 200 ~ 500 USD└───────────────────────────────────────────────┘

3.2 Redis 캐싱 추가

// src/lib/cache.ts
import { Redis } from '@upstash/redis'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

export async function getCached<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600 // 1시간
): Promise<T> {
  // 캐시 확인
  const cached = await redis.get<T>(key)
  if (cached !== null) {
    return cached
  }

  // 캐시 미스 - 데이터 가져오기
  const data = await fetcher()

  // 캐시에 저장
  await redis.set(key, data, { ex: ttl })

  return data
}

export async function invalidateCache(pattern: string) {
  const keys = await redis.keys(pattern)
  if (keys.length > 0) {
    await redis.del(...keys)
  }
}

// 사용 예시
// const user = await getCached(
//   `user:${userId}`,
//   () => db.user.findUnique({ where: { id: userId } }),
//   1800 // 30분
// )

3.3 백그라운드 작업 큐

// src/lib/queue.ts
import { Queue, Worker, Job } from 'bullmq'
import { Redis } from 'ioredis'

const connection = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: null,
})

// 큐 정의
export const emailQueue = new Queue('email', { connection })
export const analyticsQueue = new Queue('analytics', { connection })

// 이메일 워커
const emailWorker = new Worker(
  'email',
  async (job: Job) => {
    const { to, subject, template, data } = job.data

    // 이메일 전송 로직
    console.log(`Sending email to ${to}: ${subject}`)
    // await resend.emails.send(...)
  },
  {
    connection,
    concurrency: 5,
    limiter: {
      max: 100,
      duration: 60000, // 분당 100건 제한
    },
  }
)

emailWorker.on('completed', (job) => {
  console.log(`Email job ${job.id} completed`)
})

emailWorker.on('failed', (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err)
})

// 큐에 작업 추가
// await emailQueue.add('welcome', {
//   to: 'user@example.com',
//   subject: '환영합니다!',
//   template: 'welcome',
//   data: { name: '사용자' }
// })

3.4 모니터링 강화

// src/lib/monitoring.ts
import * as Sentry from '@sentry/nextjs'

// 커스텀 성능 모니터링
export function trackApiPerformance(
  name: string,
  fn: () => Promise<Response>
): Promise<Response> {
  return Sentry.startSpan(
    { name, op: 'http.server' },
    async () => {
      const start = Date.now()
      try {
        const response = await fn()
        const duration = Date.now() - start

        // 느린 API 경고
        if (duration > 2000) {
          Sentry.captureMessage(`Slow API: ${name} took ${duration}ms`, 'warning')
        }

        return response
      } catch (error) {
        Sentry.captureException(error)
        throw error
      }
    }
  )
}

// 비즈니스 메트릭 추적
export function trackBusinessEvent(
  event: string,
  properties?: Record<string, unknown>
) {
  // PostHog로 비즈니스 이벤트 전송
  // posthog.capture(event, properties)
  console.log(`[Business Event] ${event}`, properties)
}

3.5 데이터베이스 최적화

-- 인덱스 최적화
-- 자주 조회되는 쿼리의 인덱스 추가
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY idx_orders_user_created
  ON orders(user_id, created_at DESC);
CREATE INDEX CONCURRENTLY idx_products_category_status
  ON products(category_id, status) WHERE status = 'active';

-- 파티셔닝 (이벤트 테이블이 커질 때)
CREATE TABLE events (
  id BIGSERIAL,
  user_id UUID NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- 쿼리 성능 분석
-- EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- SELECT * FROM orders WHERE user_id = 'xxx' ORDER BY created_at DESC LIMIT 20;

4. Stage 2: Growth (월 2K-10K달러)

4.1 성장 단계 기술 스택

┌───────────────────────────────────────────────┐
Stage 2 기술 스택                       │
├───────────────────────────────────────────────┤
│                                               │
Compute:     AWS ECS Fargate / GKEDatabase:    RDS PostgreSQL Multi-AZCache:       ElastiCache Redis ClusterCDN:         CloudFront + S3Queue:       SQS / RabbitMQSearch:      ElasticSearch / OpenSearchCI/CD:       GitHub Actions + ArgoCDMonitoring:  Datadog / Grafana StackLog:         CloudWatch / LokiIaC:         Terraform│                                               │
│  팀 규모: 10-30명                              │
│  월 비용: 2,000 ~ 10,000 USD└───────────────────────────────────────────────┘

4.2 Kubernetes 도입

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
      - name: api
        image: my-registry/api-server:latest
        ports:
        - containerPort: 3000
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: url
        - name: REDIS_URL
          valueFrom:
            secretKeyRef:
              name: redis-credentials
              key: url
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 15
          periodSeconds: 20
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

4.3 CI/CD 파이프라인

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

env:
  AWS_REGION: ap-northeast-2
  ECR_REPOSITORY: my-startup/api
  ECS_SERVICE: api-service
  ECS_CLUSTER: production

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check
      - run: npm run test

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: task-definition.json
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

4.4 Terraform 인프라 코드

# terraform/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-startup-terraform-state"
    key    = "production/terraform.tfstate"
    region = "ap-northeast-2"
  }
}

# RDS PostgreSQL
resource "aws_db_instance" "main" {
  identifier     = "my-startup-production"
  engine         = "postgres"
  engine_version = "16.1"
  instance_class = "db.r6g.large"

  allocated_storage     = 100
  max_allocated_storage = 500
  storage_encrypted     = true

  multi_az               = true
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  backup_retention_period = 14
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  performance_insights_enabled = true
  monitoring_interval          = 60

  tags = {
    Environment = "production"
    Service     = "database"
  }
}

# ElastiCache Redis
resource "aws_elasticache_replication_group" "main" {
  replication_group_id       = "my-startup-cache"
  description                = "Redis cache cluster"
  node_type                  = "cache.r6g.large"
  num_cache_clusters         = 2
  automatic_failover_enabled = true
  multi_az_enabled           = true
  engine_version             = "7.0"
  port                       = 6379
  subnet_group_name          = aws_elasticache_subnet_group.main.name
  security_group_ids         = [aws_security_group.redis.id]

  at_rest_encryption_enabled = true
  transit_encryption_enabled = true
}

5. Stage 3: Scale (월 10K달러 이상)

5.1 스케일 단계 아키텍처

┌─────────────────────────────────────────────────────────┐
Stage 3 아키텍처                            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐          │
│  │CloudFront│───▶│ ALB/NLB  │───▶│ K8s      │          │
│  │  + WAF   │    │          │    │ Cluster  │          │
│  └──────────┘    └──────────┘    └──────────┘          │
│                                       │                 │
│                     ┌─────────────────┼──────────┐     │
│                     │                 │          │     │
│                     ▼                 ▼          ▼     │
│              ┌───────────┐   ┌───────────┐ ┌────────┐ │
│              │API Gateway│Worker    │ │ Cron   │ │
│              │  Service  │   │  Service   │ │Service │ │
│              └─────┬─────┘   └─────┬─────┘ └───┬────┘ │
│                    │               │            │      │
│      ┌─────────────┼───────────────┼────────────┤     │
│      │             │               │            │     │
│      ▼             ▼               ▼            ▼     │
│  ┌────────┐  ┌─────────┐   ┌──────────┐  ┌────────┐ │
│  │RDS     │  │Redis    │   │  Kafka   │  │  S3    │ │
│  │Multi-AZ│  │Cluster  │   │ Cluster  │  │        │ │
│  └────────┘  └─────────┘   └──────────┘  └────────┘ │
│                                    │                  │
│                              ┌─────┴─────┐           │
│                              │ElasticSearch│           │
│                              │  Cluster   │           │
│                              └───────────┘           │
│                                                       │
│  팀 규모: 30+│  월 비용: 10,000 USD+└─────────────────────────────────────────────────────────┘

5.2 이벤트 기반 아키텍처 (Kafka)

// src/events/producer.ts
import { Kafka, Partitioners } from 'kafkajs'

const kafka = new Kafka({
  clientId: 'my-startup',
  brokers: (process.env.KAFKA_BROKERS || '').split(','),
  ssl: true,
  sasl: {
    mechanism: 'scram-sha-256',
    username: process.env.KAFKA_USERNAME!,
    password: process.env.KAFKA_PASSWORD!,
  },
})

const producer = kafka.producer({
  createPartitioner: Partitioners.DefaultPartitioner,
  idempotent: true,
})

export async function publishEvent(topic: string, event: {
  type: string
  payload: Record<string, unknown>
  metadata?: Record<string, unknown>
}) {
  await producer.connect()

  const message = {
    key: event.payload.id as string || crypto.randomUUID(),
    value: JSON.stringify({
      ...event,
      timestamp: new Date().toISOString(),
      version: '1.0',
    }),
    headers: {
      'event-type': event.type,
      'content-type': 'application/json',
    },
  }

  await producer.send({
    topic,
    messages: [message],
  })
}

// 사용 예시
// await publishEvent('orders', {
//   type: 'order.created',
//   payload: { id: orderId, userId, items, total },
// })

5.3 ElasticSearch 통합

// src/lib/search.ts
import { Client } from '@elastic/elasticsearch'

const esClient = new Client({
  node: process.env.ELASTICSEARCH_URL!,
  auth: {
    apiKey: process.env.ELASTICSEARCH_API_KEY!,
  },
})

// 인덱스 생성
export async function createProductIndex() {
  await esClient.indices.create({
    index: 'products',
    body: {
      settings: {
        analysis: {
          analyzer: {
            korean: {
              type: 'custom',
              tokenizer: 'nori_tokenizer',
              filter: ['nori_readingform', 'lowercase'],
            },
          },
        },
      },
      mappings: {
        properties: {
          name: {
            type: 'text',
            analyzer: 'korean',
            fields: { keyword: { type: 'keyword' } },
          },
          description: { type: 'text', analyzer: 'korean' },
          category: { type: 'keyword' },
          price: { type: 'integer' },
          tags: { type: 'keyword' },
          created_at: { type: 'date' },
        },
      },
    },
  })
}

// 검색
export async function searchProducts(query: string, filters?: {
  category?: string
  minPrice?: number
  maxPrice?: number
  page?: number
  size?: number
}) {
  const must: Record<string, unknown>[] = []
  const filter: Record<string, unknown>[] = []

  if (query) {
    must.push({
      multi_match: {
        query,
        fields: ['name^3', 'description', 'tags^2'],
        type: 'best_fields',
        fuzziness: 'AUTO',
      },
    })
  }

  if (filters?.category) {
    filter.push({ term: { category: filters.category } })
  }

  if (filters?.minPrice || filters?.maxPrice) {
    const range: Record<string, number> = {}
    if (filters.minPrice) range.gte = filters.minPrice
    if (filters.maxPrice) range.lte = filters.maxPrice
    filter.push({ range: { price: range } })
  }

  const result = await esClient.search({
    index: 'products',
    body: {
      query: {
        bool: { must, filter },
      },
      from: ((filters?.page || 1) - 1) * (filters?.size || 20),
      size: filters?.size || 20,
      highlight: {
        fields: { name: {}, description: {} },
      },
    },
  })

  return {
    total: (result.hits.total as { value: number }).value,
    hits: result.hits.hits.map((hit) => ({
      ...hit._source,
      score: hit._score,
      highlights: hit.highlight,
    })),
  }
}

6. 프론트엔드 스택

6.1 프레임워크 비교

항목Next.js 15RemixSvelteKit
렌더링SSR/SSG/ISR/RSCSSR/CSRSSR/SSG/CSR
서버 컴포넌트있음 (RSC)없음없음 (rune 사용)
라우팅파일 기반 (App Router)파일 기반파일 기반
데이터 페칭Server Components, fetchLoader/ActionLoad 함수
배포Vercel 최적화, 어디서나어디서나어디서나
생태계매우 큼중간성장 중
학습 곡선중간-높음중간낮음-중간
스타트업 추천강력 추천추천소규모 팀 추천

스타트업 결론: 2025년 기준 Next.js가 가장 안전한 선택. 생태계, 채용 시장, 배포 인프라 모든 면에서 우위.

6.2 UI 컴포넌트 전략

추천 스택:
├── Tailwind CSS ........... 유틸리티 퍼스트 CSS
├── shadcn/ui .............. 복사-붙여넣기 컴포넌트 (소유권)
├── Radix UI ............... 접근성 기반 헤드리스 컴포넌트
├── Framer Motion .......... 애니메이션
└── Lucide Icons ........... 아이콘 세트

대안:
├── Mantine ................ 올인원 UI 라이브러리
├── Ark UI ................. Chakra UI 팀의 헤드리스 컴포넌트
└── Park UI ................ Ark UI + Tailwind

6.3 상태 관리

라이브러리복잡도용도스타트업 추천
React useState/useContext최소로컬 상태기본
Zustand낮음글로벌 상태강력 추천
Jotai낮음원자적 상태추천
TanStack Query중간서버 상태필수
Redux Toolkit높음복잡한 상태비추천 (초기)
// Zustand 예시 - 간단한 스토어
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartStore {
  items: Array<{ id: string; name: string; price: number; quantity: number }>
  addItem: (item: { id: string; name: string; price: number }) => void
  removeItem: (id: string) => void
  clearCart: () => void
  total: () => number
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id)
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
              ),
            }
          }
          return { items: [...state.items, { ...item, quantity: 1 }] }
        }),
      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
        })),
      clearCart: () => set({ items: [] }),
      total: () =>
        get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    { name: 'cart-storage' }
  )
)

7. 백엔드 스택

7.1 언어 비교 (스타트업 관점)

항목Node.js (TS)GoPythonJava/Kotlin
개발 속도빠름중간빠름느림
성능중간매우 높음낮음높음
채용 난이도쉬움중간쉬움중간
풀스택 가능예 (Next.js)아니요제한적아니요
생태계매우 큼매우 큼 (ML)매우 큼
스타트업 추천도최고성능 중요 시ML/데이터 시엔터프라이즈

7.2 스타트업별 추천

B2C SaaSNode.js (TypeScript) + Next.js
├── 풀스택 한 명이 모든 것을 할 수 있음
├── Vercel 배포로 인프라 고민 최소화
└── 프론트엔드-백엔드 타입 공유

B2B SaaSNode.js or Go
├── API 성능이 중요하면 Go
├── 빠른 기능 개발이 중요하면 Node.js
└── 둘 다 좋은 선택

AI/ML 스타트업 → Python + TypeScript
├── ML 파이프라인은 Python
├── API 서버는 FastAPI (Python) 또는 Next.js (TS)
└── 프론트엔드는 Next.js

핀테크 → Go or Java/Kotlin
├── 높은 성능과 안정성 필요
├── 정적 타입 시스템의 안전성
└── 금융 규제 대응에 강한 생태계

게이밍/리얼타임 → Go or Rust
├── 높은 동시성 처리
├── WebSocket 서버 성능
└── 낮은 지연시간

7.3 API 설계 패턴

// src/app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'

// 요청 검증 스키마
const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  description: z.string().max(5000).optional(),
  price: z.number().positive(),
  category: z.string(),
  tags: z.array(z.string()).max(10).optional(),
})

// GET /api/v1/products
export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '1')
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100)
  const category = searchParams.get('category')

  const supabase = await createClient()

  let query = supabase
    .from('products')
    .select('*', { count: 'exact' })
    .eq('status', 'active')
    .range((page - 1) * limit, page * limit - 1)
    .order('created_at', { ascending: false })

  if (category) {
    query = query.eq('category', category)
  }

  const { data, count, error } = await query

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({
    data,
    pagination: {
      page,
      limit,
      total: count || 0,
      totalPages: Math.ceil((count || 0) / limit),
    },
  })
}

// POST /api/v1/products
export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const body = await req.json()
  const parsed = createProductSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: parsed.error.flatten() },
      { status: 400 }
    )
  }

  const { data, error } = await supabase
    .from('products')
    .insert({ ...parsed.data, user_id: user.id })
    .select()
    .single()

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data }, { status: 201 })
}

8. 데이터베이스

8.1 PostgreSQL vs MySQL

항목PostgreSQLMySQL
JSON 지원JSONB (인덱싱 가능)JSON (제한적)
전문 검색내장 (tsvector)내장 (FULLTEXT)
확장성확장 풍부 (PostGIS 등)제한적
복제논리적/물리적물리적 (기본)
성능읽기/쓰기 균형읽기 최적화
스타트업 추천강력 추천MySQL 경험 팀이면 OK

8.2 BaaS 비교

서비스DB 엔진가격 (시작)특징스타트업 추천
SupabasePostgreSQL무료Auth, Storage, Realtime 포함MVP 최고
NeonPostgreSQL무료서버리스, 브랜칭좋음
PlanetScaleMySQL (Vitess)무료브랜칭, 무중단 스키마 변경좋음
TursoSQLite (libSQL)무료에지, 매우 빠름소규모 앱
CockroachDBPostgreSQL 호환유료분산 SQL, 글로벌대규모 앱

8.3 Redis 도입 시점

Redis를 도입해야 하는 시점:
├── 동일 쿼리가 초당 100회 이상 실행될 때
├── API 응답 시간이 500ms를 넘길 때
├── 세션 관리가 필요할 때
├── 실시간 기능 (리더보드, 카운터 등)이 필요할 때
├── Rate Limiting이 필요할 때
└── 분산 잠금(Distributed Lock)이 필요할 때

Redis를 아직 도입하지 않아도 되는 시점:
├── DAU 1,000 이하
├── DB 쿼리가 20ms 이내
├── Next.js의 내장 캐시로 충분할 때
└── 비용이 제약이 될 때

9. 인프라

9.1 PaaS에서 IaaS로의 전환 시점

Vercel/Railway에서 AWS/GCP로 이동해야 하는 시점:

1. 비용 임계점
   ├── Vercel Pro 비용이 월 500달러를 넘길 때
   ├── 대역폭 비용이 컴퓨팅보다 클 때
   └── 함수 실행 시간 제한에 걸릴 때

2. 기술적 요구사항
   ├── Long-running 프로세스가 필요할  (5+ 이상)
   ├── WebSocket이 핵심 기능일 때
   ├── GPU 컴퓨팅이 필요할 때
   └── 커스텀 네트워크 구성이 필요할 때

3. 규제/컴플라이언스
   ├── 데이터 저장 위치 규제
   ├── SOC 2, HIPAA 등 인증 필요
   └── VPC 격리가 필요할 때

이동하지 않아도 되는 경우:
├── 월 비용 500달러 이하
├── 트래픽이 예측 가능하고 안정적
├── 서버리스 아키텍처로 충분
└── DevOps 전담 인력이 없음

9.2 클라우드 비교

항목AWSGCPAzureVercel
스타트업 크레딧10K-100K200K (구글 포 스타트업)150K없음 (무료 티어)
서비스 다양성최고높음높음프론트엔드 특화
학습 곡선높음중간높음낮음
한국 리전서울서울부산인천 (Edge)
스타트업 추천시리즈 A+ML 중심엔터프라이즈MVP-PMF

9.3 비용 최적화 체크리스트

  1. Spot/Preemptible 인스턴스 활용 (최대 90% 절약)
  2. Reserved Instances 1년 약정 (최대 40% 절약)
  3. Right-sizing: 실제 사용량 기반 인스턴스 크기 조정
  4. Auto-scaling: 트래픽에 따른 자동 스케일링
  5. S3 수명 주기 정책: 오래된 데이터를 Glacier로 이동
  6. CloudFront 캐싱: 오리진 요청 최소화
  7. NAT Gateway 비용 주의: 데이터 전송 비용이 의외로 큼
  8. 개발/스테이징 환경: 야간/주말 자동 중단

10. 채용과 기술 스택

10.1 기술 스택이 채용에 미치는 영향

기술 스택 선택은 곧 채용 풀을 결정합니다:

기술한국 개발자 풀주니어 비율시니어 채용 난이도
React/Next.js매우 큼높음중간
TypeScript중간중간
Python매우 큼높음ML 분야 어려움
Go작음낮음높음
Rust매우 작음매우 낮음매우 높음
Java/Kotlin매우 큼높음중간
PostgreSQL중간중간
Kubernetes중간낮음높음

10.2 채용 친화적 기술 스택

채용이 쉬운 스택 (한국 기준):
├── Next.js + TypeScript (프론트)
├── Node.js + Express/Nest.js (백엔드)
├── PostgreSQL / MySQL (DB)
├── Redis (캐시)
├── Docker + GitHub Actions (DevOps)
└── AWS (인프라)

채용이 어려운 스택:
├── Svelte / SolidJS (프론트)
├── Rust / Elixir (백엔드)
├── CockroachDB / ScyllaDB (DB)
├── Nomad / Consul (오케스트레이션)
└── Pulumi / CDK (IaC)

10.3 기술 면접에서 스택 설명하기

채용 과정에서 기술 스택 선택 이유를 설명하는 것이 중요합니다:

  • 왜 이 기술을 선택했는지 (비즈니스 맥락)
  • 트레이드오프는 무엇이었는지 (선택하지 않은 기술)
  • 언제 변경할 계획인지 (기술 로드맵)

11. 흔한 실수

11.1 이력서 주도 개발 (Resume-Driven Development)

잘못된 의사결정 패턴:
├── "K8s 경험을 쌓고 싶어서"K8s 도입 (DAU 100)
├── "Rust가 트렌드라서"Rust로 API 서버 작성 (2)
├── "MSA가 정석이라서"3명이 10개 서비스 운영
├── "GraphQL이 좋다고 해서"REST로 충분한 CRUDGraphQL
└── "모노레포가 구글에서 쓰니까"3명 팀에 Turborepo 셋업

올바른 의사결정:
├── "이 문제를 해결하는 가장 간단한 방법은?"
├── "6개월 뒤에도 유지보수할 수 있는가?"
├── "팀원들이 이미 익숙한 기술인가?"
├── "채용이 용이한 기술인가?"
└── "전환 비용이 감당 가능한가?"

11.2 조기 최적화

  • DB 인덱스 40개 미리 생성 (쿼리 패턴도 모르는 상태에서)
  • 캐싱 레이어 3단계 구축 (트래픽 100명/일)
  • 글로벌 CDN 설정 (사용자 모두 한국)
  • 로드 밸런서 + Auto Scaling (CPU 사용률 5%)

11.3 조기 마이크로서비스

3명의 팀이 마이크로서비스를 도입하면:

  • 서비스 간 통신 디버깅에 하루 종일
  • 분산 트랜잭션 문제로 데이터 불일치
  • 배포 파이프라인 10개 관리
  • 모니터링 대시보드 세팅만 2주
  • 결과: 개발 속도 50% 감소, 인프라 비용 300% 증가

11.4 기술 부채를 무시하는 것

반대로, 기술 부채를 너무 쌓는 것도 문제입니다:

  • 테스트 없이 6개월 개발 후 리팩토링 불가능
  • 타입 없이 JavaScript로 만 줄 코드 작성
  • 하드코딩된 설정값이 곳곳에 산재
  • 로깅/모니터링 없이 운영하다 장애 대응 불가

균형: MVP 단계에서는 80/20 법칙 적용. 20%의 핵심 코드에 80%의 품질을 투자하세요.


12. 실제 사례

12.1 Vercel 스택

Vercel (Next.js 개발사):
├── Frontend: Next.js (자사 제품)
├── Backend: Go + Node.js
├── Database: PlanetScale (MySQL)Neon (PostgreSQL)
├── Cache: Redis (Upstash)
├── Search: Turborepo 기반 모노레포
├── Deploy: 자사 플랫폼
├── Monitoring: 자체 구축 + Datadog
└── 특징: 자사 제품을 적극 활용 (dogfooding)

12.2 Linear 스택

Linear (프로젝트 관리 도구):
├── Frontend: React + TypeScript
├── Backend: Node.js + TypeScript
├── Database: PostgreSQL
├── Cache: Redis
├── Sync: CRDT 기반 실시간 동기화
├── Deploy: Google Cloud
├── Monitoring: 자체 구축
└── 특징: 오프라인 퍼스트, 로컬 데이터 우선

12.3 Cal.com 스택

Cal.com (오픈소스 일정 관리):
├── Frontend: Next.js + TypeScript
├── Backend: tRPC + Prisma
├── Database: PostgreSQL
├── Auth: NextAuth.js
├── Email: 다양한 프로바이더 지원
├── Deploy: Vercel
├── Monorepo: Turborepo
└── 특징: 오픈소스, 모노레포, tRPC로 타입 안전

12.4 시사점

이 사례들의 공통점:

  1. 검증된 기술 위주 (PostgreSQL, Redis, TypeScript)
  2. 모놀리스 또는 모듈러 모놀리스로 시작
  3. TypeScript를 기본으로 채택
  4. PaaS 우선 (Vercel, Google Cloud 매니지드)
  5. 핵심 차별화 포인트에만 혁신 투자

13. 퀴즈

Q1: "Boring Technology" 원칙에서 "혁신 토큰"이란 무엇이며, 왜 아껴야 하나요?

A: 혁신 토큰은 Dan McKinley가 제안한 개념으로, 팀이 감당할 수 있는 새로운/검증되지 않은 기술의 수를 의미합니다. 모든 팀에는 제한된 수(보통 3개 정도)의 혁신 토큰이 있으며, 검증되지 않은 기술을 도입할 때마다 하나씩 소비됩니다.

아껴야 하는 이유: 스타트업의 비즈니스 모델 자체가 이미 하나의 혁신이므로 토큰 1개를 사용합니다. 나머지 토큰을 기술 스택에서 모두 소진하면, 문제 발생 시 대응할 여력이 없습니다. 검증된(지루한) 기술은 예측 가능한 문제만 발생하고, 해결 방법이 이미 알려져 있어 운영 부담을 줄여줍니다.

Q2: MVP 단계에서 왜 Next.js + Supabase + Vercel 조합이 추천되나요?

A: 이 조합이 추천되는 이유:

  1. 개발 속도: Next.js의 풀스택 기능으로 프론트/백 분리 없이 개발 가능
  2. 비용: 세 서비스 모두 무료 티어가 충분히 관대 (월 0-50달러)
  3. 인프라 관리 제로: Vercel이 배포/CDN/SSL 모두 처리, Supabase가 DB/Auth/Storage 처리
  4. 타입 안전성: TypeScript로 풀스택 타입 공유
  5. 확장성: Supabase는 PostgreSQL 기반이라 추후 마이그레이션 용이
  6. 생태계: shadcn/ui, Stripe, Resend 등 통합이 매우 쉬움
  7. 채용: React/Next.js 개발자 풀이 가장 큼

핵심은 PMF를 찾기 전에 인프라에 시간을 쓰지 않는 것입니다.

Q3: PaaS(Vercel)에서 IaaS(AWS)로 전환해야 하는 시점은 언제인가요?

A: 전환 시점 판단 기준:

  1. 비용: Vercel 비용이 월 500달러 이상일 때 (동일 워크로드를 AWS에서 더 저렴하게 운영 가능)
  2. 기술 제약: 5분 이상의 Long-running 프로세스, WebSocket 기반 실시간 기능, GPU 컴퓨팅 필요 시
  3. 규제: 데이터 저장 위치 규제, SOC 2/HIPAA 인증, VPC 격리 필요 시
  4. : DevOps/인프라 전담 인력이 확보되었을 때

전환하지 않아도 되는 경우: 월 비용 500달러 이하, 서버리스 아키텍처로 충분, DevOps 전담자 없음. 핵심은 "필요할 때" 전환하는 것이지, 미리 준비하는 것이 아닙니다.

Q4: 스타트업에서 마이크로서비스를 처음부터 도입하면 안 되는 이유는?

A: 처음부터 마이크로서비스를 도입하면 안 되는 이유:

  1. 서비스 경계 실패: PMF 전에는 도메인이 계속 변하므로 서비스 경계를 올바르게 정하기 불가능
  2. 운영 오버헤드: 서비스 간 통신, 분산 트랜잭션, 서비스 디스커버리 등 복잡도 폭발
  3. 디버깅 어려움: 분산 시스템 디버깅은 모놀리스보다 10배 어려움
  4. 인프라 비용: 각 서비스별 배포 파이프라인, 모니터링, 로깅 필요 (비용 3배 이상)
  5. 팀 규모 미스매치: 3-5명 팀이 10개 서비스를 관리하는 것은 비현실적

올바른 접근: 모놀리스로 시작, 도메인 이해가 충분해진 후 병목 지점만 선택적으로 서비스 분리.

Q5: 기술 스택 선택이 채용에 미치는 영향은 무엇인가요?

A: 기술 스택은 채용에 직접적인 영향을 미칩니다:

  1. 채용 풀 크기: React/TypeScript 개발자는 매우 많지만, Rust/Elixir 개발자는 극소수
  2. 채용 속도: 인기 기술 스택은 지원자가 많아 빠르게 채용 가능
  3. 채용 비용: 희귀 기술은 프리미엄 연봉 필요 (Go 시니어 > Node.js 시니어 by 20-30%)
  4. 온보딩: 팀원이 이미 아는 기술이면 온보딩 1주 이내, 새로운 기술이면 1개월 이상
  5. 이직률: 시장에서 인기 없는 기술은 커리어 불안으로 이직률 상승

추천: 한국 기준으로 TypeScript + React + PostgreSQL + AWS는 가장 채용이 용이한 조합입니다.


참고 자료

  1. McKinley, D. "Choose Boring Technology." https://boringtechnology.club/
  2. Fowler, M. "Monolith First." Martin Fowler's blog.
  3. Next.js Documentation (2025). https://nextjs.org/docs
  4. Supabase Documentation. https://supabase.com/docs
  5. Vercel Documentation. https://vercel.com/docs
  6. Stripe Documentation. https://stripe.com/docs
  7. Terraform AWS Provider Documentation.
  8. "The Twelve-Factor App." https://12factor.net/
  9. Kleppmann, M. "Designing Data-Intensive Applications." O'Reilly.
  10. Newman, S. "Building Microservices, 2nd Edition." O'Reilly.
  11. Basecamp. "Getting Real." https://basecamp.com/gettingreal
  12. Linear Engineering Blog. https://linear.app/blog
  13. Cal.com GitHub Repository. https://github.com/calcom/cal.com
  14. AWS Startup Guides. https://aws.amazon.com/startups/
  15. Google for Startups Cloud Program. https://cloud.google.com/startup
  16. Y Combinator Library - Technical Decisions. https://www.ycombinator.com/library