-
API Gateway 플랫폼의 Stage 아키텍처 설계: dev → staging → prod 환경 분리 전략실제 경험과 인사이트를 AI와 함께 정리한 글 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 Production1. 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:#e8f5e9Override 규칙:
- ✅ 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