티스토리 뷰

작성일: 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)
  • ✗ 환경 간 차이로 인한 버그 ("내 로컬에선 되는데?")

우리의 요구사항

  1. 경제성: 하나의 Pod/Database로 모든 환경 처리
  2. 간결성: 환경 전환이 URL 경로만 다름 (/dev/* vs /prod/*)
  3. 독립성: dev 코드 수정이 prod에 영향 없음
  4. 즉시성: 배포 = DB 업데이트 (재시작 불필요)
  5. 안전성: 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로 고정했는가?

고려한 대안:

  1. 동적 Stage 생성 (예: dev, qa, uat, hotfix, feature-xxx)
    • ❌ 복잡도 증가 (무한 생성 가능)
    • ❌ UI/UX 혼란 (어떤 Stage에 배포해야 하나?)
    • ❌ Plugin 설정 관리 어려움
  2. 2 Stage (dev, prod만)
    • ❌ staging 없으면 prod 직행 (위험)
    • ❌ QA 팀의 독립 테스트 환경 부족
  3. 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)

✅ 잘한 결정

  1. 고정 3 Stage: 동적 Stage 대비 압도적 간결성
  2. name에 Stage prefix: 기존 아키텍처 최소 변경
  3. 조건부 Route 생성: Plugin 없으면 Route 안 만듦 (효율성)
  4. Promotion Pipeline 선택적: 팀 규모/상황에 맞게 on/off
  5. 환경변수는 Application 전역: Stage별 격리 아님 (혼란 방지)

⚠️ 개선이 필요한 부분

  1. Function Document 중복: 3배 저장 (dev + staging + prod)
    • 해결 방향: 코드 압축, S3 오프로드
  2. baseName 기반 조회: Function 전체 Stage 조회 시 3번 쿼리
    • 해결 방향: FunctionMetadata로 관계 정리
  3. Plugin 계층 복잡도: Application → Stage → Function
    • 해결 방향: UI에서 최종 Merged Plugin 미리보기 제공
  4. Rollback 히스토리 관리: 무한 저장 시 DB 증가
    • 해결 방향: 히스토리 보관 정책 (최근 N개만)

🔄 다시 설계한다면

  1. Git 기반 배포: Function 코드를 Git에 저장
    • 장점: Version Control, Diff, Blame 기본 제공
    • 단점: 외부 의존성, 복잡도 증가
  2. Canary/Blue-Green: Stage 외에 배포 전략 추가
    • 장점: 점진적 배포, A/B 테스트
    • 단점: 인프라 복잡도 증가
  3. 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

언제 사용하나?

✅ 이 아키텍처가 적합한 경우:

  1. 서버리스 Function 플랫폼
    • 사용자가 코드 작성 → 즉시 API 엔드포인트
    • 환경 분리 필수 (dev에서 개발 → prod 배포)
  2. 멀티 테넌트 SaaS
    • 테넌트당 1개 Application
    • 인프라 비용 최소화 필요
    • 환경별 독립 설정 필요
  3. Low-code/No-code 플랫폼
    • 사용자가 직접 배포 수행
    • Git 기반 배포 불필요
    • 웹 IDE에서 클릭 한 번으로 배포
  4. CI/CD Pipeline 구현
    • dev → staging → prod 순차 배포 강제
    • 환경 간 코드 복사 및 검증
    • Rollback 지원

❌ 부적합한 경우:

  1. 완전한 환경 격리 필요
    • 환경마다 별도 Database/Secret 필요
    • 규제 준수 (GDPR, HIPAA 등)
      → 별도 Namespace × 3 방식 권장
  2. Git 기반 배포 선호
    • 코드 버전 관리를 Git으로 하고 싶은 경우
    • git merge/rebase로 환경 간 코드 이동
      → GitOps 방식 권장
  3. 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 오픈소스 프로젝트에서 실제로 운영 중입니다.

기여 방법:


참고 자료

환경 분리 전략

배포 파이프라인

관련 프로젝트


태그: #Serverless #Kubernetes #DevOps #CICD #Multi-Stage #Environment-Segregation #OpenSource

저자: imprun.dev 팀
GitHub: imprun/imprun

💡 핵심 메시지: 고정 3 Stage + URL Path Prefix 전략으로 인프라 비용을 67% 절감하면서도 환경 독립성을 보장할 수 있습니다. 간결함이 곧 확장성입니다.


질문이나 피드백이 있다면 GitHub Discussion에서 공유해주세요!

이 글이 도움이 되셨다면 ⭐ Star와 공유 부탁드립니다!

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/02   »
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
글 보관함