ABOUT ME

-

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

    우리의 요구사항

    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와 공유 부탁드립니다!

Designed by Tistory.