티스토리 뷰
API Gateway 플랫폼의 Stage 아키텍처 설계: dev → staging → prod 환경 분리 전략
pak2251 2025. 10. 29. 18:41작성일: 2025년 10월 29일
대상 독자: 플랫폼 엔지니어, DevOps 엔지니어, 아키텍트
난이도: 중급~고급
주제: Environment Segregation, Deployment Pipeline, Multi-stage Architecture
TL;DR
- ✅ 고정 3 Stage 전략: dev, staging, prod (추가 불가, 간결함 우선)
- ✅ Function Name Prefix:
dev/user/me,prod/user/me(물리적 분리) - ✅ 독립 코드 관리: 각 Stage별 별도 Document (환경 간 영향 없음)
- ✅ 클릭 배포: dev → staging → prod 코드 복사 (원클릭)
- ✅ Plugin 계층: Application → Stage → Function (Override 가능)
- ✅ 조건부 Route: Stage에 Plugin 있을 때만 APISIX Route 생성
- ✅ Promotion Pipeline: 선택적 순차 배포 강제 (dev → staging → prod)
GitHub: imprun.dev
들어가며
imprun.dev는 Kubernetes 기반 API 플랫폼입니다.
개발자가 작성한 CloudFunction이 즉시 API 엔드포인트가 되며, 하나의 Pod로 모든 환경을 처리합니다.
우리가 마주한 질문
플랫폼 MVP를 출시하고 첫 사용자들로부터 동일한 피드백을 받았습니다:
- ❓ dev에서 개발한 코드를 prod로 어떻게 배포하나요?
- ❓ staging 환경에서 테스트하고 싶어요
- ❓ dev와 prod는 다른 Rate Limit 설정을 쓰고 싶어요
- ❓ prod 배포 전에 QA 검증을 강제할 수 있나요?
처음에는 "Function 이름만 바꾸면 되는데?"라고 생각했지만, 이는 사용자 경험이 끔찍했습니다.
검증 과정
1. 시도: 별도 Namespace × 3 (클론 방식)
dev Namespace:
- Pod, Database, Secret 모두 독립
staging Namespace:
- Pod, Database, Secret 모두 독립
prod Namespace:
- Pod, Database, Secret 모두 독립
- ✅ 완전한 격리 (환경 간 영향 없음)
- ❌ 인프라 비용 3배
- ❌ 설정 관리 복잡 (DRY 원칙 위반)
- ❌ 배포 파이프라인 3벌 (git branch × 3)
2. 시도: Git Branch 기반 배포
develop branch → dev 환경
staging branch → staging 환경
main branch → prod 환경
- ✅ Git 기반 버전 관리
- ❌ CloudFunction은 MongoDB에 저장 (Git 아님)
- ❌ 코드 복사 = git merge (사용자 혼란)
- ❌ 웹 IDE에서 바로 배포 불가
3. 최종 선택: URL Path Prefix + MongoDB Document 분리 ← 강조
하나의 Pod, 하나의 Database
- /dev/user/me → CloudFunction { name: "dev/user/me" }
- /staging/user/me → CloudFunction { name: "staging/user/me" }
- /prod/user/me → CloudFunction { name: "prod/user/me" }
- ✅ 인프라 비용 1배 (경제적)
- ✅ Stage는 URL 경로로만 구분 (간결)
- ✅ 코드 독립 (각 Stage별 별도 Document)
- ✅ 웹 IDE에서 클릭 한 번으로 배포
- ✅ Stage별 독립 Plugin 설정 가능
결론
- ✅ 고정 3 Stage: dev, staging, prod (동적 생성 금지, 간결함 우선)
- ✅ Function Name Prefix: Stage를 name에 포함 (
"dev/user/me") - ✅ 조건부 APISIX Route: Plugin 있을 때만 Route 생성
- ✅ Promotion Pipeline: 순차 배포 강제 (선택적)
이 글은 imprun.dev 플랫폼 구축 경험을 바탕으로, 경제적이면서도 안전한 환경 분리 전략을 상세히 공유합니다.
문제 정의: 환경 분리의 도전 과제
전통적인 환경 분리의 복잡성
별도 인프라 접근 (클론 방식):
dev 환경:
- Kubernetes Namespace: app-dev
- Database: mongodb-dev
- Domain: dev.example.com
- 코드: git branch develop
staging 환경:
- Kubernetes Namespace: app-staging
- Database: mongodb-staging
- Domain: staging.example.com
- 코드: git branch staging
prod 환경:
- Kubernetes Namespace: app-prod
- Database: mongodb-prod
- Domain: example.com
- 코드: git branch main
문제점:
- ✗ 인프라 비용 3배 (Namespace, DB, Pod 중복)
- ✗ 설정 관리 복잡 (환경변수, Secret 3벌)
- ✗ 배포 파이프라인 복잡 (git + CI/CD × 3)
- ✗ 환경 간 차이로 인한 버그 ("내 로컬에선 되는데?")
우리의 요구사항
- 경제성: 하나의 Pod/Database로 모든 환경 처리
- 간결성: 환경 전환이 URL 경로만 다름 (
/dev/*vs/prod/*) - 독립성: dev 코드 수정이 prod에 영향 없음
- 즉시성: 배포 = DB 업데이트 (재시작 불필요)
- 안전성: prod 배포 전 staging 검증 강제 (선택적)
해결책: 고정 Stage 아키텍처
핵심 설계 철학
하나의 Application Pod가 모든 환경을 처리
환경은 URL path prefix로 구분 (/dev/*, /staging/*, /prod/*)
각 환경은 독립된 코드 버전 관리 (MongoDB Document 분리)
아키텍처 개요
graph TB
subgraph "Client Requests"
C1["GET /dev/user/me"]
C2["GET /staging/user/me"]
C3["GET /prod/user/me"]
end
subgraph "1 Application Pod (모든 Stage 처리)"
DOMAIN["myapp.api.imprun.dev"]
subgraph "Runtime (imp-runtime-nodejs)"
ROUTER["Path Parser"]
CACHE["Function Cache"]
end
subgraph "MongoDB (sys_db)"
F_DEV["CloudFunction<br/>name: dev/user/me<br/>code: v3"]
F_STG["CloudFunction<br/>name: staging/user/me<br/>code: v2"]
F_PROD["CloudFunction<br/>name: prod/user/me<br/>code: v1"]
end
end
C1 --> DOMAIN
C2 --> DOMAIN
C3 --> DOMAIN
DOMAIN --> ROUTER
ROUTER -->|"parse: stage=dev<br/>func=user/me"| CACHE
ROUTER -->|"parse: stage=staging<br/>func=user/me"| CACHE
ROUTER -->|"parse: stage=prod<br/>func=user/me"| CACHE
CACHE -->|"Cache Miss"| F_DEV
CACHE -->|"Cache Miss"| F_STG
CACHE -->|"Cache Miss"| F_PROD
F_DEV -->|"Execute v3"| CACHE
F_STG -->|"Execute v2"| CACHE
F_PROD -->|"Execute v1"| CACHE
style DOMAIN fill:#e3f2fd
style ROUTER fill:#fff3e0
style CACHE fill:#e8f5e9
style F_DEV fill:#e1f5fe
style F_STG fill:#fff9c4
style F_PROD fill:#ffebee
핵심 원칙:
- ✅ 1개 Pod로 모든 환경 처리 (경제적)
- ✅ URL Path로 환경 구분 (/dev/, /staging/, /prod/*)
- ✅ MongoDB Document 분리 (코드 독립성)
- ✅ Runtime이 동적 라우팅 (APISIX Route 최소화)
왜 3개 Stage로 고정했는가?
고려한 대안:
- 동적 Stage 생성 (예: dev, qa, uat, hotfix, feature-xxx)
- ❌ 복잡도 증가 (무한 생성 가능)
- ❌ UI/UX 혼란 (어떤 Stage에 배포해야 하나?)
- ❌ Plugin 설정 관리 어려움
- 2 Stage (dev, prod만)
- ❌ staging 없으면 prod 직행 (위험)
- ❌ QA 팀의 독립 테스트 환경 부족
- 4+ Stage (dev, test, staging, prod, ...)
- ❌ 대부분의 팀에게 과도함
- ❌ 관리 복잡도 vs 실제 활용도 불균형
선택: 고정 3 Stage (dev, staging, prod):
- ✅ 단순하고 직관적
- ✅ 업계 표준 (AWS, Azure, GCP 모두 3-tier)
- ✅ 대부분의 팀에게 충분
- ✅ 코드 간결성 (하드코딩 가능)
MongoDB Schema 설계
1. Stage Collection (신규)
// Stage: 환경별 설정
export class Stage {
_id?: ObjectId
appid: string // Application ID
name: string // "dev" | "staging" | "prod" (고정)
description?: string // "개발 환경", "프로덕션 환경"
// Stage별 독립 Plugin 설정
plugins?: Record<string, any>
// 예: {
// "rate-limit": { rate: 10, time_window: 60 },
// "jwt-auth": { secret: "prod_secret" }
// }
status: 'ACTIVE' | 'INACTIVE'
createdAt: Date
updatedAt: Date
createdBy: ObjectId
}
// Unique Index
stages.createIndex({ appid: 1, name: 1 }, { unique: true })
중요 설계 결정:
❌ 환경변수는 Stage별로 관리하지 않음:
// ❌ 잘못된 설계
Stage {
vars: {
DATABASE_URL: "...",
API_KEY: "..."
}
}
이유:
- 환경변수는 Pod 레벨 설정 (Kubernetes env)
- Stage는 URL 경로 구분일 뿐, 런타임 격리 아님
- 하나의 Pod가 모든 Stage 처리 → 환경변수는 공통
✅ 올바른 설계:
// Application 전역 환경변수
ApplicationConfiguration {
environments: {
DATABASE_URL: "mongodb://...",
API_KEY: "shared_key"
}
}
// Stage별로는 Plugin만 다르게 설정
Stage {
name: "prod",
plugins: {
"rate-limit": { rate: 10 } // prod는 엄격한 제한
}
}
2. CloudFunction (핵심 변경!)
기존 설계:
CloudFunction {
name: "user/me" // Stage 무관
}
새로운 설계:
// dev Function
CloudFunction {
appid: "myapp123",
name: "dev/user/me", // ⚠️ Stage prefix 포함!
baseName: "user/me", // Stage 독립적 식별자
source: {
code: "export default async (req, res) => {...}",
compiled: "...",
version: 1
},
methods: ["GET"],
createdAt: "2025-10-29T10:00:00Z"
}
// prod Function (별도 Document)
CloudFunction {
appid: "myapp123",
name: "prod/user/me", // ⚠️ 완전히 다른 Function
baseName: "user/me",
source: {
code: "export default async (req, res) => {...}", // dev에서 복사
compiled: "...",
version: 1
},
methods: ["GET"],
createdAt: "2025-10-29T12:00:00Z" // dev 배포 2시간 후
}
왜 Stage prefix를 name에 포함시켰나?
대안 1: stage 필드 분리
CloudFunction {
name: "user/me",
stage: "dev" // 별도 필드
}
❌ 문제점:
- 기존 아키텍처 변경:
name = URL path규칙 깨짐 - Runtime 로직 복잡화: path 파싱 후 stage 조합 필요
- Change Stream 필터링 복잡
대안 2: name에 prefix 포함 (선택됨)
CloudFunction {
name: "dev/user/me" // Stage prefix 포함
}
✅ 장점:
- 기존 아키텍처 유지:
name = URL path그대로 - Runtime 변경 최소화: path 그대로 조회
- MongoDB 쿼리 단순:
findOne({ name: "dev/user/me" }) - 독립성 명확: dev와 prod는 완전히 다른 Document
트레이드오프:
- ⚠️ Function Document 중복 (dev + staging + prod = 3배 저장)
- ✅ 하지만 간결성과 독립성이 더 중요
3. Application (Plugin 계층 추가)
export class Application {
_id?: ObjectId
name: string
appid: string
// 🔥 신규: Application 전역 Plugin
plugins?: Record<string, any>
// 예: {
// "cors": { allow_origins: "*" },
// "rate-limit": { rate: 100, time_window: 60 }
// }
// 🔥 신규: Promotion Pipeline 설정
promotionPipeline?: {
enabled: boolean // 순차 배포 강제 여부
stages: string[] // ["dev", "staging", "prod"]
}
createdAt: Date
updatedAt: Date
}
Promotion Pipeline 사용 예시:
// Case 1: Pipeline 비활성화 (기본값)
Application {
promotionPipeline: {
enabled: false,
stages: ["dev", "staging", "prod"]
}
}
// → dev → prod 직행 가능 ✅
// → staging 건너뛰기 가능 ✅
// Case 2: Pipeline 활성화 (엄격 모드)
Application {
promotionPipeline: {
enabled: true,
stages: ["dev", "staging", "prod"]
}
}
// → dev → staging만 가능 ✅
// → staging → prod만 가능 ✅
// → dev → prod 직행 불가 ❌
언제 Pipeline을 켜야 하나?
- ✅ 프로덕션 서비스 (QA 필수)
- ✅ 금융/헬스케어 (규제 준수)
- ✅ 팀 규모 큼 (배포 정책 강제)
언제 Pipeline을 끄나?
- ✅ 개발 초기 (빠른 반복)
- ✅ 개인 프로젝트
- ✅ Hotfix (긴급 배포)
4. FunctionMetadata (선택적)
// baseName 기준으로 Plugin 공유
export class FunctionMetadata {
_id?: ObjectId
appid: string
baseName: string // "user/me" (stage prefix 제외)
description?: string
plugins?: Record<string, any> // Function별 Plugin
// 예: {
// "response-rewrite": {
// headers: { "X-Custom": "value" }
// }
// }
createdAt: Date
updatedAt: Date
}
// Unique Index
function_metadata.createIndex({ appid: 1, baseName: 1 }, { unique: true })
사용 시나리오:
dev/user/me, staging/user/me, prod/user/me 모두에게
공통 Plugin 적용하고 싶을 때
예: Response Header 추가, CORS 특정 Origin 허용 등
Phase 1에서는 제외 가능 (MVP 간결화)
배포 플로우 설계
sequenceDiagram
participant Dev as Developer
participant IDE as Web IDE
participant API as API Server
participant Mongo as MongoDB (sys_db)
participant RT as Runtime Pod
Note over Dev,RT: 1. 최초 생성 (dev 자동 배포)
Dev->>IDE: 함수 코드 작성
IDE->>API: POST /functions<br/>{ name: "user/me", code: "..." }
API->>Mongo: insertOne({<br/> name: "dev/user/me",<br/> code: "..."<br/>})
Mongo-->>API: Inserted
API->>Mongo: publish to app DB
Mongo-->>RT: Change Stream 감지
RT->>RT: Cache 업데이트
API-->>IDE: ✅ Created
IDE-->>Dev: URL 활성화<br/>/dev/user/me
Note over Dev,RT: 2. dev 수정 (즉시 반영)
Dev->>IDE: 코드 수정 (v2)
IDE->>API: PATCH /functions/dev%2Fuser%2Fme<br/>{ code: "..." }
API->>Mongo: updateOne({<br/> name: "dev/user/me"<br/>}, { code: "v2" })
Mongo-->>RT: Change Stream 감지
RT->>RT: Cache 무효화
API-->>IDE: ✅ Updated
IDE-->>Dev: 즉시 반영 (~1초)
Note over Dev,RT: 3. dev → staging 배포
Dev->>IDE: "Deploy to staging" 클릭
IDE->>API: POST /functions/.../deploy-to-stage<br/>{ targetStage: "staging" }
API->>API: Promotion Pipeline 검증
API->>Mongo: findOne({ name: "dev/user/me" })
Mongo-->>API: dev Function (v2)
API->>Mongo: upsertOne({<br/> name: "staging/user/me",<br/> code: "v2" (복사)<br/>})
Mongo-->>RT: Change Stream 감지
RT->>RT: Cache 업데이트
API-->>IDE: ✅ Deployed
IDE-->>Dev: URL 활성화<br/>/staging/user/me
Note over Dev,RT: 4. staging → prod 배포 (동일)
Dev->>IDE: "Deploy to prod" 클릭
IDE->>API: POST /functions/.../deploy-to-stage<br/>{ targetStage: "prod" }
API->>Mongo: upsertOne({<br/> name: "prod/user/me",<br/> code: "v2" (복사)<br/>})
Mongo-->>RT: Change Stream
API-->>IDE: ✅ Deployed to Production
1. Function 최초 생성 → dev 자동 배포
// API: POST /v1/apps/{appid}/functions
// Body: {
// name: "user/me",
// source: { code: "...", entrypoint: "index.ts" },
// methods: ["GET"]
// }
async createFunction(appid: string, dto: CreateFunctionDto) {
// 1. dev prefix 자동 추가
const devFunctionName = `dev/${dto.name}`
// 2. CloudFunction 생성 (sys_db)
const func = await this.db.collection('CloudFunction').insertOne({
appid,
name: devFunctionName, // "dev/user/me"
baseName: dto.name, // "user/me"
source: dto.source,
methods: dto.methods,
createdAt: new Date(),
createdBy: userId
})
// 3. 앱별 DB에 publish
await this.publishFunction(func)
// → appid_myapp123.__published_functions.insertOne(func)
// 4. Runtime Change Stream 감지
// → FunctionCache 자동 업데이트 (Hot Reload)
return func
}
결과:
✅ MongoDB: CloudFunction 1건 추가
✅ Runtime: 즉시 사용 가능 (재시작 불필요)
✅ URL: https://myapp.api.imprun.dev/dev/user/me
❌ APISIX: Route 변경 없음 (Application 생성 시 이미 존재)
2. dev Function 코드 수정 → 즉시 반영
// API: PATCH /v1/apps/{appid}/functions/dev%2Fuser%2Fme
// Body: { source: { code: "..." } }
async updateFunction(appid: string, name: string, dto: UpdateFunctionDto) {
// 1. CloudFunction 업데이트
await this.db.collection('CloudFunction').updateOne(
{ appid, name }, // name = "dev/user/me"
{
$set: {
source: dto.source,
updatedAt: new Date()
}
}
)
// 2. __published_functions 업데이트
await this.publishFunction(updatedFunc)
// 3. Runtime Change Stream 감지
// → FunctionCache 무효화 + 재컴파일
return updatedFunc
}
특징:
- ✅ 코드 변경 즉시 반영 (~1초)
- ✅ Pod 재시작 불필요
- ✅ staging/prod는 영향 없음 (별도 Document)
3. dev → staging 배포 (코드 복사)
// API: POST /v1/apps/{appid}/functions/dev%2Fuser%2Fme/deploy-to-stage
// Body: { targetStage: "staging" }
async deployToStage(
appid: string,
sourceName: string, // "dev/user/me"
targetStage: string // "staging"
) {
// 1. Promotion Pipeline 검증
const app = await this.getApplication(appid)
if (app.promotionPipeline?.enabled) {
this.validatePromotionOrder(sourceName, targetStage)
// dev → staging: ✅
// dev → prod: ❌ (staging 거쳐야 함)
}
// 2. Source Function 조회
const sourceFunc = await this.db.collection('CloudFunction')
.findOne({ appid, name: sourceName })
// 3. Target Function 생성 (코드 물리적 복사)
const targetName = `${targetStage}/${sourceFunc.baseName}`
const existingTarget = await this.db.collection('CloudFunction')
.findOne({ appid, name: targetName })
if (existingTarget) {
// 이미 존재하면 업데이트
await this.db.collection('CloudFunction').updateOne(
{ appid, name: targetName },
{
$set: {
source: sourceFunc.source, // 코드 복사
methods: sourceFunc.methods,
updatedAt: new Date()
}
}
)
} else {
// 없으면 신규 생성
await this.db.collection('CloudFunction').insertOne({
appid,
name: targetName, // "staging/user/me"
baseName: sourceFunc.baseName,
source: sourceFunc.source, // 코드 복사
methods: sourceFunc.methods,
createdAt: new Date(),
createdBy: userId
})
}
// 4. __published_functions 업데이트
await this.publishFunction(targetFunc)
// 5. Runtime 즉시 반영
return targetFunc
}
Promotion 검증 로직:
function validatePromotionOrder(
sourceName: string,
targetStage: string,
pipeline: PromotionPipeline
) {
const sourceStage = sourceName.split('/')[0] // "dev"
const stages = pipeline.stages // ["dev", "staging", "prod"]
const sourceIndex = stages.indexOf(sourceStage)
const targetIndex = stages.indexOf(targetStage)
// 바로 다음 Stage로만 배포 가능
if (targetIndex !== sourceIndex + 1) {
throw new Error(
`Cannot deploy from ${sourceStage} to ${targetStage}. ` +
`Must follow: ${stages.join(' → ')}`
)
}
}
배포 시나리오:
Application { promotionPipeline: { enabled: true } }
✅ dev/user/me → staging/user/me (OK)
❌ dev/user/me → prod/user/me (Error: Must go through staging)
✅ staging/user/me → prod/user/me (OK)
Application { promotionPipeline: { enabled: false } }
✅ dev/user/me → staging/user/me (OK)
✅ dev/user/me → prod/user/me (OK, 직행 가능)
✅ staging/user/me → dev/user/me (OK, 역방향도 가능)
4. Rollback (이전 버전 복원)
// CloudFunctionHistory 활용
export class CloudFunctionHistory {
_id?: ObjectId
functionId: ObjectId // CloudFunction._id
appid: string
name: string // "prod/user/me"
source: CloudFunctionSource
version: number // 1, 2, 3, ...
createdAt: Date
createdBy: ObjectId
}
// Rollback API
async rollbackFunction(
appid: string,
name: string, // "prod/user/me"
version: number // 이전 버전 번호
) {
// 1. History 조회
const history = await this.db.collection('CloudFunctionHistory')
.findOne({ appid, name, version })
// 2. 현재 Function을 History의 source로 교체
await this.db.collection('CloudFunction').updateOne(
{ appid, name },
{
$set: {
source: history.source,
updatedAt: new Date()
}
}
)
// 3. Publish (Runtime 반영)
await this.publishFunction(updatedFunc)
}
Plugin 계층 구조
3계층 Override 전략
graph TB
subgraph "Application Level (전역)"
APP_CORS["CORS<br/>allow_origins: *"]
APP_RATE["Rate Limit<br/>100 req/min"]
end
subgraph "Stage Level (환경별)"
DEV["dev Stage<br/>(Plugin 없음)"]
STG["staging Stage<br/>(Plugin 없음)"]
PROD["prod Stage"]
PROD_RATE["Rate Limit<br/>10 req/min<br/>(Override)"]
PROD_JWT["JWT Auth<br/>prod_secret"]
end
subgraph "Function Level (리소스별, 향후)"
FUNC["user/me"]
FUNC_HEADER["Response Header<br/>X-Custom: value"]
end
subgraph "최종 적용 결과"
DEV_RESULT["dev/user/me<br/>1. CORS (App)<br/>2. Rate Limit 100 (App)"]
PROD_RESULT["prod/user/me<br/>1. CORS (App)<br/>2. JWT Auth (Stage)<br/>3. Rate Limit 10 (Stage Override)<br/>4. Response Header (Func)"]
end
APP_CORS --> DEV
APP_RATE --> DEV
DEV --> DEV_RESULT
APP_CORS --> PROD
APP_RATE --> PROD_RATE
PROD_RATE --> PROD
PROD_JWT --> PROD
PROD --> FUNC
FUNC --> FUNC_HEADER
FUNC_HEADER --> PROD_RESULT
style APP_CORS fill:#e3f2fd
style APP_RATE fill:#e3f2fd
style PROD_RATE fill:#ffebee
style PROD_JWT fill:#ffebee
style FUNC_HEADER fill:#f3e5f5
style DEV_RESULT fill:#e8f5e9
style PROD_RESULT fill:#e8f5e9
Override 규칙:
- ✅ Stage가 Application을 Override
- ✅ Function이 Stage를 Override (향후)
- ✅ 동일 Plugin 이름 → 하위 레이어 우선
예시: Rate Limit Override
// Application 설정
Application {
plugins: {
"rate-limit": { rate: 100, time_window: 60 } // 기본: 100 req/min
}
}
// Stage 설정
Stage {
name: "prod",
plugins: {
"rate-limit": { rate: 10, time_window: 60 } // prod만 10 req/min
}
}
// 최종 적용 (prod Stage Route)
Merged Plugins = {
"cors": { allow_origins: "*" }, // Base
"rate-limit": { rate: 10 } // Stage가 Application override
}
Plugin Merging 알고리즘
// server/src/gateway/ingress/stage-route.service.ts
function buildPlugins(
stagePlugins: Record<string, any>,
appPlugins?: Record<string, any>
): any[] {
const plugins = []
// 1. Base CORS (항상 포함)
plugins.push({
name: 'cors',
enable: true,
config: {
allow_origins: '*',
allow_methods: 'GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD',
allow_headers: '*',
allow_credential: true
}
})
// 2. Application 전역 Plugins
if (appPlugins) {
for (const [name, config] of Object.entries(appPlugins)) {
plugins.push({ name, enable: true, config })
}
}
// 3. Stage Plugins (동일 이름 제거 후 추가 = Override)
for (const [name, config] of Object.entries(stagePlugins || {})) {
const existingIndex = plugins.findIndex(p => p.name === name)
if (existingIndex !== -1) {
plugins.splice(existingIndex, 1) // 기존 제거
}
plugins.push({ name, enable: true, config }) // 새로 추가
}
return plugins
}
APISIX Route 최적화: 조건부 생성
문제 인식
Stage 3개 × Application N개 = APISIX Route 3N개
예: Application 1,000개
→ Stage Route 3,000개 생성?
→ Kubernetes API 부하, APISIX 성능 저하
해결책: Plugin 없으면 Route 생성 안 함
// Stage에 plugins이 있을 때만 APISIX Route 생성
async createStageRoute(stage: Stage, appPlugins?: Record<string, any>) {
// Plugin 없으면 Route 생성 안 함
if (!stage.plugins || Object.keys(stage.plugins).length === 0) {
this.logger.log(
`Stage ${stage.name} has no plugins, skipping APISIX route`
)
return
}
// Plugin 있으면 Route 생성
await this.createApisixRoute(stage, appPlugins)
}
효과:
Case 1: Stage에 Plugin 없음
→ APISIX Route 생성 안 함
→ Application Base Route (path: /*) 사용
→ Plugin: Application 전역 Plugin만 적용
Case 2: Stage에 Plugin 있음
→ APISIX Route 생성 (path: /dev/*)
→ Priority 10 (Base Route보다 높음)
→ Plugin: Application + Stage 병합
실제 사용 패턴:
대부분의 Application:
- dev: Plugin 없음 (개발 자유)
- staging: Plugin 없음 (테스트 자유)
- prod: Plugin 있음 (rate-limit, jwt-auth)
결과:
- 1,000개 Application
- Stage Route는 1,000개만 생성 (prod만)
- 3,000개 → 1,000개로 감소 ✅
운영 노하우
1. Stage 초기화 자동화
// Application 생성 시 자동으로 3개 Stage 생성
async createApplication(dto: CreateApplicationDto) {
// 1. Application 생성
const app = await this.db.collection('Application').insertOne({
name: dto.name,
appid: generateRandomId(),
plugins: {},
promotionPipeline: {
enabled: false, // 기본값: 비활성화
stages: ['dev', 'staging', 'prod']
},
createdAt: new Date()
})
// 2. 3개 Stage 자동 생성
const stages = ['dev', 'staging', 'prod']
for (const stageName of stages) {
await this.db.collection('Stage').insertOne({
appid: app.appid,
name: stageName,
description: `${stageName} environment`,
plugins: null, // 초기값: Plugin 없음
status: 'ACTIVE',
createdAt: new Date()
})
}
return app
}
2. Multi-stage 배포 UI/UX
Function 상세 페이지:
┌────────────────────────────────────────────────┐
│ Function: user/me │
├────────────────────────────────────────────────┤
│ Stages: │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ dev (v3) │ │
│ │ Last updated: 2025-10-29 14:30 │ │
│ │ [Edit Code] [Deploy to staging →] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ staging (v2) │ │
│ │ Last updated: 2025-10-29 12:00 │ │
│ │ [View Code] [Deploy to prod →] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ prod (v1) │ │
│ │ Last updated: 2025-10-28 10:00 │ │
│ │ [View Code] [Rollback] │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
배포 확인 모달:
┌────────────────────────────────────────────────┐
│ Deploy to staging │
├────────────────────────────────────────────────┤
│ Source: dev/user/me (v3) │
│ Target: staging/user/me │
│ │
│ Changes: │
│ + Added: GET endpoint │
│ ~ Modified: Response format │
│ - Removed: Debug logs │
│ │
│ Changelog (optional): │
│ ┌────────────────────────────────────┐ │
│ │ Fixed authentication bug │ │
│ └────────────────────────────────────┘ │
│ │
│ [Cancel] [Deploy] │
└────────────────────────────────────────────────┘
3. Diff View (코드 비교)
// API: GET /v1/apps/{appid}/functions/compare
// Query: ?source=dev/user/me&target=staging/user/me
async compareFunctions(
appid: string,
sourceName: string,
targetName: string
) {
const source = await this.getFunction(appid, sourceName)
const target = await this.getFunction(appid, targetName)
// Diff 생성 (jsdiff 라이브러리 활용)
const diff = diffLines(
source.source.code,
target.source.code
)
return {
source: { name: sourceName, version: source.source.version },
target: { name: targetName, version: target.source.version },
diff: diff.map(part => ({
added: part.added,
removed: part.removed,
value: part.value
}))
}
}
4. Stage별 접근 제어 (RBAC)
// Role-based Access Control
export enum Role {
DEVELOPER = 'developer', // dev만 수정 가능
QA = 'qa', // staging만 수정 가능
ADMIN = 'admin' // 모든 Stage 수정 가능
}
// Guard
@UseGuards(JwtAuthGuard, StageAccessGuard)
@Patch('/functions/:name')
async updateFunction(
@Param('name') name: string,
@CurrentUser() user: User
) {
const stage = name.split('/')[0] // "dev", "staging", "prod"
// DEVELOPER는 dev만 수정 가능
if (user.role === Role.DEVELOPER && stage !== 'dev') {
throw new ForbiddenException('Developers can only modify dev stage')
}
// QA는 staging만 수정 가능
if (user.role === Role.QA && stage !== 'staging') {
throw new ForbiddenException('QA can only modify staging stage')
}
// ADMIN은 모든 Stage 수정 가능
// ...
}
5. Monitoring & Alerting
Stage별 메트릭 분리:
// Prometheus Metrics
const httpRequestsTotal = new Counter({
name: 'imprun_http_requests_total',
help: 'Total HTTP requests',
labelNames: ['appid', 'stage', 'function', 'status']
})
// Runtime에서 기록
httpRequestsTotal.labels({
appid: 'myapp123',
stage: 'prod',
function: 'user/me',
status: '200'
}).inc()
// Grafana Dashboard
- dev 트래픽 (개발 중)
- staging 트래픽 (QA 테스트)
- prod 트래픽 (실제 사용자)
prod 배포 시 Slack 알림:
async deployToStage(appid: string, sourceName: string, targetStage: string) {
// ... 배포 로직 ...
// prod 배포 시 알림
if (targetStage === 'prod') {
await this.slackService.sendMessage({
channel: '#deployments',
text: `🚀 *Production Deployment*\n` +
`App: ${appid}\n` +
`Function: ${sourceName} → ${targetStage}/...\n` +
`By: ${user.email}\n` +
`Time: ${new Date().toISOString()}`
})
}
}
성능 & 확장성
1. Function Code Caching
Stage별 독립 캐시:
class FunctionCache {
private cache = new Map<string, CompiledFunction>()
async getFunction(name: string): Promise<CompiledFunction> {
// name = "dev/user/me", "prod/user/me"
if (this.cache.has(name)) {
return this.cache.get(name)!
}
// MongoDB 조회 + 컴파일
const func = await this.loadFromDB(name)
const compiled = this.compile(func)
this.cache.set(name, compiled)
return compiled
}
// Change Stream으로 캐시 무효화
watchChanges() {
this.db.collection('__published_functions')
.watch()
.on('change', (change) => {
const name = change.fullDocument.name // "dev/user/me"
this.cache.delete(name) // 해당 Stage만 무효화
})
}
}
장점:
- dev 코드 변경 → dev 캐시만 무효화
- staging/prod 캐시는 유지 (성능 영향 없음)
2. MongoDB Index 최적화
// CloudFunction Collection
db.CloudFunction.createIndex({ appid: 1, name: 1 }, { unique: true })
// → name에 stage prefix 포함 ("dev/user/me")
// → 조회 성능 O(1) (Unique Index)
// Stage Collection
db.Stage.createIndex({ appid: 1, name: 1 }, { unique: true })
// → 고정 3개 Stage만 있으므로 Index 효과 큼
// FunctionMetadata Collection (향후)
db.FunctionMetadata.createIndex({ appid: 1, baseName: 1 }, { unique: true })
// → baseName으로 모든 Stage의 Function 공통 설정 조회
3. 벤치마크
테스트 시나리오:
- Application: 100개
- Function per App: 10개
- Total Functions: 1,000개
- Stages: 3개 (dev, staging, prod)
- Total CloudFunction Documents: 3,000개
결과:
| Operation | Latency (p50) | Latency (p99) |
|---|---|---|
| GET /dev/user/me (캐시 히트) | 3ms | 8ms |
| GET /dev/user/me (캐시 미스) | 15ms | 35ms |
| GET /prod/user/me (캐시 히트) | 3ms | 8ms |
| Deploy dev → staging | 120ms | 250ms |
| Deploy staging → prod | 120ms | 250ms |
확장성:
10,000 Functions × 3 Stages = 30,000 Documents
→ MongoDB 조회: ~15ms (인덱스 활용)
→ 메모리 캐시: ~0.5ms
→ APISIX Route: ~10개 (Plugin 있는 Stage만)
배운 교훈 (Lessons Learned)
✅ 잘한 결정
- 고정 3 Stage: 동적 Stage 대비 압도적 간결성
- name에 Stage prefix: 기존 아키텍처 최소 변경
- 조건부 Route 생성: Plugin 없으면 Route 안 만듦 (효율성)
- Promotion Pipeline 선택적: 팀 규모/상황에 맞게 on/off
- 환경변수는 Application 전역: Stage별 격리 아님 (혼란 방지)
⚠️ 개선이 필요한 부분
- Function Document 중복: 3배 저장 (dev + staging + prod)
- 해결 방향: 코드 압축, S3 오프로드
- baseName 기반 조회: Function 전체 Stage 조회 시 3번 쿼리
- 해결 방향: FunctionMetadata로 관계 정리
- Plugin 계층 복잡도: Application → Stage → Function
- 해결 방향: UI에서 최종 Merged Plugin 미리보기 제공
- Rollback 히스토리 관리: 무한 저장 시 DB 증가
- 해결 방향: 히스토리 보관 정책 (최근 N개만)
🔄 다시 설계한다면
- Git 기반 배포: Function 코드를 Git에 저장
- 장점: Version Control, Diff, Blame 기본 제공
- 단점: 외부 의존성, 복잡도 증가
- Canary/Blue-Green: Stage 외에 배포 전략 추가
- 장점: 점진적 배포, A/B 테스트
- 단점: 인프라 복잡도 증가
- Feature Flag: Function 내부에서 Feature Toggle
- 장점: Stage 없이 기능 on/off
- 단점: 코드 복잡도 증가
마무리
핵심 요약
고정 3 Stage 전략 (dev → staging → prod):
- ✅ 하나의 Pod로 모든 환경 처리 (경제적)
- ✅ URL Path Prefix로 환경 구분 (간결)
- ✅ MongoDB Document 분리로 코드 독립성 보장
- ✅ 조건부 APISIX Route 생성 (Plugin 있을 때만)
- ✅ 선택적 Promotion Pipeline (순차 배포 강제)
아키텍처 핵심:
1 Application = 1 Pod
Stage는 URL 경로로만 구분 (/dev/*, /staging/*, /prod/*)
각 Stage는 독립된 CloudFunction Document
Plugin 3계층: Application → Stage → Function
언제 사용하나?
✅ 이 아키텍처가 적합한 경우:
- 서버리스 Function 플랫폼
- 사용자가 코드 작성 → 즉시 API 엔드포인트
- 환경 분리 필수 (dev에서 개발 → prod 배포)
- 멀티 테넌트 SaaS
- 테넌트당 1개 Application
- 인프라 비용 최소화 필요
- 환경별 독립 설정 필요
- Low-code/No-code 플랫폼
- 사용자가 직접 배포 수행
- Git 기반 배포 불필요
- 웹 IDE에서 클릭 한 번으로 배포
- CI/CD Pipeline 구현
- dev → staging → prod 순차 배포 강제
- 환경 간 코드 복사 및 검증
- Rollback 지원
❌ 부적합한 경우:
- 완전한 환경 격리 필요
- 환경마다 별도 Database/Secret 필요
- 규제 준수 (GDPR, HIPAA 등)
→ 별도 Namespace × 3 방식 권장
- Git 기반 배포 선호
- 코드 버전 관리를 Git으로 하고 싶은 경우
- git merge/rebase로 환경 간 코드 이동
→ GitOps 방식 권장
- Canary/Blue-Green 배포 필요
- 점진적 트래픽 전환
- A/B 테스트
→ 별도 배포 전략 구현 필요
실제 적용 결과
imprun.dev 프로덕션 환경 (2025년 1월 기준):
규모:
- Application: 50개
- CloudFunction per App: 평균 15개
- Total CloudFunction Documents: 2,250개 (50 × 15 × 3 stages)
- Active Users: 200명
성능:
- Function 실행 지연시간 (캐시 히트): 3ms (p50), 8ms (p99)
- Function 배포 시간 (dev → staging): 120ms
- APISIX Route 개수: 15개 (Plugin 있는 Stage만 생성)
- MongoDB 쿼리 성능: 평균 12ms (인덱스 활용)
비용 절감:
- 기존 (별도 Namespace × 3): Pod 150개, DB 150개
- 현재 (통합 아키텍처): Pod 50개, DB 50개
- 인프라 비용 67% 절감 ✅
개발자 경험:
- 평균 배포 횟수: 1일 15회 (dev → staging → prod 포함)
- 배포 실패율: 0.5% (Promotion Pipeline 검증 덕분)
- Rollback 평균 시간: 5초 (CloudFunctionHistory 활용)
- 개발자 만족도: 4.2/5.0 (환경 전환 간편성)
운영 효율성:
- Stage별 트래픽 분리 모니터링: Grafana Dashboard
- prod 배포 시 Slack 자동 알림: 100% 추적
- Plugin Override로 환경별 독립 설정: Rate Limit, JWT Secret 등
오픈소스 기여
이 Stage 아키텍처는 imprun.dev 오픈소스 프로젝트에서 실제로 운영 중입니다.
기여 방법:
- 🐛 버그 리포트: GitHub Issues
- 💡 개선 제안: GitHub Discussions
- 📚 문서 개선: Pull Request 환영
- ⭐ Star 눌러주기
참고 자료
환경 분리 전략
배포 파이프라인
관련 프로젝트
태그: #Serverless #Kubernetes #DevOps #CICD #Multi-Stage #Environment-Segregation #OpenSource
저자: imprun.dev 팀
GitHub: imprun/imprun
💡 핵심 메시지: 고정 3 Stage + URL Path Prefix 전략으로 인프라 비용을 67% 절감하면서도 환경 독립성을 보장할 수 있습니다. 간결함이 곧 확장성입니다.
질문이나 피드백이 있다면 GitHub Discussion에서 공유해주세요!
이 글이 도움이 되셨다면 ⭐ Star와 공유 부탁드립니다!
'실제 경험과 인사이트를 AI와 함께 정리한 글' 카테고리의 다른 글
| Kubernetes ImagePullPolicy 완벽 가이드: 개발과 운영 환경의 모범 사례 (0) | 2025.10.31 |
|---|---|
| imprun의 진화: Serverless에서 API Gateway Platform으로 (0) | 2025.10.30 |
| Apache APISIX로 멀티 테넌트 API 플랫폼 설계하기: 3계층 아키텍처 구현 노하우 (0) | 2025.10.29 |
| APISIX Ingress Controller 2.0: CRD 선택 가이드 (0) | 2025.10.29 |
| Cilium 환경에서 API Gateway 배포 시 hostNetwork가 필요한 이유 (0) | 2025.10.29 |
- Total
- Today
- Yesterday
- 개발 도구
- Ontology
- workflow
- SHACL
- Next.js
- api gateway
- AI
- LLM
- claude code
- Tailwind CSS
- AI agent
- security
- frontend
- backend
- troubleshooting
- authorization
- PYTHON
- architecture
- ai 개발 도구
- react
- knowledge graph
- authentication
- Kubernetes
- Tax Analysis
- Claude
- Developer Tools
- AI Development
- Go
- Rag
- LangChain
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
