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

- Name
- Youngju Kim
- @fjvbn20031
- 1. 기술 스택 철학
- 2. Stage 0: MVP (월 0-50달러)
- 3. Stage 1: Product-Market Fit (월 200-500달러)
- 4. Stage 2: Growth (월 2K-10K달러)
- 5. Stage 3: Scale (월 10K달러 이상)
- 6. 프론트엔드 스택
- 7. 백엔드 스택
- 8. 데이터베이스
- 9. 인프라
- 10. 채용과 기술 스택
- 11. 흔한 실수
- 12. 실제 사례
- 13. 퀴즈
- 참고 자료
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.js │
│ Storage: Supabase Storage / Cloudflare R2 │
│ Deploy: Vercel │
│ Analytics: PostHog (self-hosted) / Plausible │
│ Payments: Stripe │
│ Email: Resend │
│ Monitoring: 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 비용 분석
| 서비스 | 무료 티어 | 유료 전환 시점 | 비용 |
|---|---|---|---|
| Vercel | 100GB 대역폭, 무제한 배포 | 트래픽 증가 시 | Pro: 20달러/월 |
| Supabase | 500MB DB, 1GB 스토리지 | 사용량 증가 시 | Pro: 25달러/월 |
| Stripe | 사용량 기반 | 매출 발생 시 | 2.9% + 30센트/건 |
| Resend | 100통/일 | 전환율 테스트 시 | 20달러/월 |
| Sentry | 5K 이벤트/월 | 에러 증가 시 | 26달러/월 |
| PostHog | 1M 이벤트/월 | 분석 고도화 시 | 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 / Typesense │
│ CDN: + Cloudflare │
│ Monitoring: + Better Stack / Grafana Cloud │
│ CI/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 / GKE │
│ Database: RDS PostgreSQL Multi-AZ │
│ Cache: ElastiCache Redis Cluster │
│ CDN: CloudFront + S3 │
│ Queue: SQS / RabbitMQ │
│ Search: ElasticSearch / OpenSearch │
│ CI/CD: GitHub Actions + ArgoCD │
│ Monitoring: Datadog / Grafana Stack │
│ Log: CloudWatch / Loki │
│ IaC: 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 15 | Remix | SvelteKit |
|---|---|---|---|
| 렌더링 | SSR/SSG/ISR/RSC | SSR/CSR | SSR/SSG/CSR |
| 서버 컴포넌트 | 있음 (RSC) | 없음 | 없음 (rune 사용) |
| 라우팅 | 파일 기반 (App Router) | 파일 기반 | 파일 기반 |
| 데이터 페칭 | Server Components, fetch | Loader/Action | Load 함수 |
| 배포 | 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) | Go | Python | Java/Kotlin |
|---|---|---|---|---|
| 개발 속도 | 빠름 | 중간 | 빠름 | 느림 |
| 성능 | 중간 | 매우 높음 | 낮음 | 높음 |
| 채용 난이도 | 쉬움 | 중간 | 쉬움 | 중간 |
| 풀스택 가능 | 예 (Next.js) | 아니요 | 제한적 | 아니요 |
| 생태계 | 매우 큼 | 큼 | 매우 큼 (ML) | 매우 큼 |
| 스타트업 추천도 | 최고 | 성능 중요 시 | ML/데이터 시 | 엔터프라이즈 |
7.2 스타트업별 추천
B2C SaaS → Node.js (TypeScript) + Next.js
├── 풀스택 한 명이 모든 것을 할 수 있음
├── Vercel 배포로 인프라 고민 최소화
└── 프론트엔드-백엔드 타입 공유
B2B SaaS → Node.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
| 항목 | PostgreSQL | MySQL |
|---|---|---|
| JSON 지원 | JSONB (인덱싱 가능) | JSON (제한적) |
| 전문 검색 | 내장 (tsvector) | 내장 (FULLTEXT) |
| 확장성 | 확장 풍부 (PostGIS 등) | 제한적 |
| 복제 | 논리적/물리적 | 물리적 (기본) |
| 성능 | 읽기/쓰기 균형 | 읽기 최적화 |
| 스타트업 추천 | 강력 추천 | MySQL 경험 팀이면 OK |
8.2 BaaS 비교
| 서비스 | DB 엔진 | 가격 (시작) | 특징 | 스타트업 추천 |
|---|---|---|---|---|
| Supabase | PostgreSQL | 무료 | Auth, Storage, Realtime 포함 | MVP 최고 |
| Neon | PostgreSQL | 무료 | 서버리스, 브랜칭 | 좋음 |
| PlanetScale | MySQL (Vitess) | 무료 | 브랜칭, 무중단 스키마 변경 | 좋음 |
| Turso | SQLite (libSQL) | 무료 | 에지, 매우 빠름 | 소규모 앱 |
| CockroachDB | PostgreSQL 호환 | 유료 | 분산 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 클라우드 비교
| 항목 | AWS | GCP | Azure | Vercel |
|---|---|---|---|---|
| 스타트업 크레딧 | 10K-100K | 200K (구글 포 스타트업) | 150K | 없음 (무료 티어) |
| 서비스 다양성 | 최고 | 높음 | 높음 | 프론트엔드 특화 |
| 학습 곡선 | 높음 | 중간 | 높음 | 낮음 |
| 한국 리전 | 서울 | 서울 | 부산 | 인천 (Edge) |
| 스타트업 추천 | 시리즈 A+ | ML 중심 | 엔터프라이즈 | MVP-PMF |
9.3 비용 최적화 체크리스트
- Spot/Preemptible 인스턴스 활용 (최대 90% 절약)
- Reserved Instances 1년 약정 (최대 40% 절약)
- Right-sizing: 실제 사용량 기반 인스턴스 크기 조정
- Auto-scaling: 트래픽에 따른 자동 스케일링
- S3 수명 주기 정책: 오래된 데이터를 Glacier로 이동
- CloudFront 캐싱: 오리진 요청 최소화
- NAT Gateway 비용 주의: 데이터 전송 비용이 의외로 큼
- 개발/스테이징 환경: 야간/주말 자동 중단
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로 충분한 CRUD에 GraphQL
└── "모노레포가 구글에서 쓰니까" → 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 시사점
이 사례들의 공통점:
- 검증된 기술 위주 (PostgreSQL, Redis, TypeScript)
- 모놀리스 또는 모듈러 모놀리스로 시작
- TypeScript를 기본으로 채택
- PaaS 우선 (Vercel, Google Cloud 매니지드)
- 핵심 차별화 포인트에만 혁신 투자
13. 퀴즈
Q1: "Boring Technology" 원칙에서 "혁신 토큰"이란 무엇이며, 왜 아껴야 하나요?
A: 혁신 토큰은 Dan McKinley가 제안한 개념으로, 팀이 감당할 수 있는 새로운/검증되지 않은 기술의 수를 의미합니다. 모든 팀에는 제한된 수(보통 3개 정도)의 혁신 토큰이 있으며, 검증되지 않은 기술을 도입할 때마다 하나씩 소비됩니다.
아껴야 하는 이유: 스타트업의 비즈니스 모델 자체가 이미 하나의 혁신이므로 토큰 1개를 사용합니다. 나머지 토큰을 기술 스택에서 모두 소진하면, 문제 발생 시 대응할 여력이 없습니다. 검증된(지루한) 기술은 예측 가능한 문제만 발생하고, 해결 방법이 이미 알려져 있어 운영 부담을 줄여줍니다.
Q2: MVP 단계에서 왜 Next.js + Supabase + Vercel 조합이 추천되나요?
A: 이 조합이 추천되는 이유:
- 개발 속도: Next.js의 풀스택 기능으로 프론트/백 분리 없이 개발 가능
- 비용: 세 서비스 모두 무료 티어가 충분히 관대 (월 0-50달러)
- 인프라 관리 제로: Vercel이 배포/CDN/SSL 모두 처리, Supabase가 DB/Auth/Storage 처리
- 타입 안전성: TypeScript로 풀스택 타입 공유
- 확장성: Supabase는 PostgreSQL 기반이라 추후 마이그레이션 용이
- 생태계: shadcn/ui, Stripe, Resend 등 통합이 매우 쉬움
- 채용: React/Next.js 개발자 풀이 가장 큼
핵심은 PMF를 찾기 전에 인프라에 시간을 쓰지 않는 것입니다.
Q3: PaaS(Vercel)에서 IaaS(AWS)로 전환해야 하는 시점은 언제인가요?
A: 전환 시점 판단 기준:
- 비용: Vercel 비용이 월 500달러 이상일 때 (동일 워크로드를 AWS에서 더 저렴하게 운영 가능)
- 기술 제약: 5분 이상의 Long-running 프로세스, WebSocket 기반 실시간 기능, GPU 컴퓨팅 필요 시
- 규제: 데이터 저장 위치 규제, SOC 2/HIPAA 인증, VPC 격리 필요 시
- 팀: DevOps/인프라 전담 인력이 확보되었을 때
전환하지 않아도 되는 경우: 월 비용 500달러 이하, 서버리스 아키텍처로 충분, DevOps 전담자 없음. 핵심은 "필요할 때" 전환하는 것이지, 미리 준비하는 것이 아닙니다.
Q4: 스타트업에서 마이크로서비스를 처음부터 도입하면 안 되는 이유는?
A: 처음부터 마이크로서비스를 도입하면 안 되는 이유:
- 서비스 경계 실패: PMF 전에는 도메인이 계속 변하므로 서비스 경계를 올바르게 정하기 불가능
- 운영 오버헤드: 서비스 간 통신, 분산 트랜잭션, 서비스 디스커버리 등 복잡도 폭발
- 디버깅 어려움: 분산 시스템 디버깅은 모놀리스보다 10배 어려움
- 인프라 비용: 각 서비스별 배포 파이프라인, 모니터링, 로깅 필요 (비용 3배 이상)
- 팀 규모 미스매치: 3-5명 팀이 10개 서비스를 관리하는 것은 비현실적
올바른 접근: 모놀리스로 시작, 도메인 이해가 충분해진 후 병목 지점만 선택적으로 서비스 분리.
Q5: 기술 스택 선택이 채용에 미치는 영향은 무엇인가요?
A: 기술 스택은 채용에 직접적인 영향을 미칩니다:
- 채용 풀 크기: React/TypeScript 개발자는 매우 많지만, Rust/Elixir 개발자는 극소수
- 채용 속도: 인기 기술 스택은 지원자가 많아 빠르게 채용 가능
- 채용 비용: 희귀 기술은 프리미엄 연봉 필요 (Go 시니어 > Node.js 시니어 by 20-30%)
- 온보딩: 팀원이 이미 아는 기술이면 온보딩 1주 이내, 새로운 기술이면 1개월 이상
- 이직률: 시장에서 인기 없는 기술은 커리어 불안으로 이직률 상승
추천: 한국 기준으로 TypeScript + React + PostgreSQL + AWS는 가장 채용이 용이한 조합입니다.
참고 자료
- McKinley, D. "Choose Boring Technology." https://boringtechnology.club/
- Fowler, M. "Monolith First." Martin Fowler's blog.
- Next.js Documentation (2025). https://nextjs.org/docs
- Supabase Documentation. https://supabase.com/docs
- Vercel Documentation. https://vercel.com/docs
- Stripe Documentation. https://stripe.com/docs
- Terraform AWS Provider Documentation.
- "The Twelve-Factor App." https://12factor.net/
- Kleppmann, M. "Designing Data-Intensive Applications." O'Reilly.
- Newman, S. "Building Microservices, 2nd Edition." O'Reilly.
- Basecamp. "Getting Real." https://basecamp.com/gettingreal
- Linear Engineering Blog. https://linear.app/blog
- Cal.com GitHub Repository. https://github.com/calcom/cal.com
- AWS Startup Guides. https://aws.amazon.com/startups/
- Google for Startups Cloud Program. https://cloud.google.com/startup
- Y Combinator Library - Technical Decisions. https://www.ycombinator.com/library