ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Environment-Agnostic Architecture: Frontend와 Backend의 환경 분리 패턴
    실제 경험과 인사이트를 AI와 함께 정리한 글 2025. 11. 3. 16:28

    작성일: 2025-11-03
    카테고리: Architecture, API Design, Frontend
    난이도: 중급


    TL;DR

    • 문제: dev/staging/prod 환경 prefix가 Frontend에 노출되어 코드 복잡도 증가 및 네트워크 낭비 (3배)
    • 해결: Environment-Agnostic Pattern - Frontend는 환경을 몰라야 한다
    • 핵심: 환경 정보는 인프라 레이어(Domain/Subdomain)에서 처리, API 응답은 baseName만 반환
    • 결과: 네트워크 비용 66% 감소, Frontend 코드 간결화, 배포 유연성 향상

    들어가며

    imprun.dev는 "API 개발부터 AI 통합까지, 모든 것을 하나로 제공"하는 Kubernetes 기반 API Gateway 플랫폼입니다. CloudFunction을 개발하고 dev/staging/prod 3개 환경에 배포하는 워크플로우를 지원합니다.

    초기 구현에서는 함수 이름을 dev/hello, staging/hello, prod/hello처럼 환경 prefix를 포함하여 저장하고, Frontend에서도 이 정보를 그대로 사용했습니다.

    우리가 마주한 문제:

    • ❓ Frontend가 dev/hello를 표시하고 있는데, 왜 환경 정보를 알아야 할까?
    • ❓ 함수 목록 조회 시 dev/hello, staging/hello, prod/hello 3개를 모두 조회해야 할까?
    • ❓ URL을 https://gateway.dev.api.imprun.dev/dev/hello처럼 중복으로 표기해야 할까?

    검증 과정:

    1. 현상 유지 (Environment 정보 Frontend 노출)

      • ✅ 구현 간단
      • ❌ Frontend 코드 복잡 (getDisplayName() 함수 9개 파일에 중복)
      • ❌ 네트워크 낭비 (3개 환경 모두 조회)
      • ❌ URL 중복 (/dev/hello)
    2. Frontend에서 환경 필터링

      • ✅ Backend 수정 불필요
      • ❌ Frontend 책임 과중
      • ❌ 네트워크 낭비 지속
    3. Environment-Agnostic Architecture최종 선택

      • ✅ Frontend는 환경을 모름 (baseName만 사용)
      • ✅ 환경 정보는 Domain/Subdomain으로 처리
      • ✅ 네트워크 비용 66% 감소 (dev 환경만 조회)
      • ✅ 코드 간결화 및 유지보수성 향상

    결론:

    • ✅ Frontend 9개 파일에서 getDisplayName() 제거
    • ✅ API 응답 크기 감소 (환경 prefix 제거)
    • ✅ 인프라 레이어에서 환경 자동 처리

    이 글은 imprun.dev 플랫폼 구축 경험을 바탕으로, Frontend와 Backend의 환경 분리 패턴을 상세히 공유합니다.


    Environment-Agnostic Pattern이란?

    핵심 원칙

    "Frontend는 환경(dev/staging/prod)을 알 필요가 없다"

    graph TB
        subgraph "Before: Environment-Aware"
            FE1[Frontend]
            API1[API Server]
            DB1["MongoDB
            dev/hello
            staging/hello
            prod/hello"]
    
            FE1 -->|"GET /functions"| API1
            API1 -->|"3개 조회"| DB1
            DB1 -->|"dev/hello
            staging/hello
            prod/hello"| API1
            API1 -->|"3개 반환"| FE1
            FE1 -->|"getDisplayName()로 변환"| FE1
        end
    
        style FE1 stroke:#dc2626,stroke-width:3px
        style DB1 stroke:#dc2626,stroke-width:2px
    graph TB
        subgraph "After: Environment-Agnostic"
            FE2[Frontend]
            API2[API Server]
            DB2["MongoDB
            dev/hello
            staging/hello
            prod/hello"]
            Infra["Infrastructure
            Subdomain Routing"]
    
            FE2 -->|"GET /functions"| API2
            API2 -->|"dev만 조회"| DB2
            DB2 -->|"dev/hello"| API2
            API2 -->|"extractBaseName()
            → hello"| FE2
            FE2 -->|"https://gateway.dev.api.imprun.dev/hello"| Infra
            Infra -->|"dev/ prefix 자동 추가"| Runtime["Runtime
            dev/hello 실행"]
        end
    
        style FE2 stroke:#16a34a,stroke-width:3px
        style API2 stroke:#16a34a,stroke-width:3px
        style Infra stroke:#2563eb,stroke-width:3px

    아키텍처 계층 분리

    계층 책임 환경 인식
    Frontend UI/UX, 사용자 입력 처리 ❌ 환경 몰라도 됨
    API Server 비즈니스 로직, 환경 prefix 자동 처리 ✅ 환경 알고 있음
    Infrastructure Subdomain 기반 라우팅 (*.dev.api.imprun.dev) ✅ 환경 처리
    Runtime Function 실행 ✅ 환경별 실행

    MongoDB 구조: 오해하기 쉬운 핵심

    ⚠️ 중요: Environment별 독립 Function 존재

    흔한 오해: "Environment-Agnostic이니까 MongoDB에 baseName만 저장하겠지?"

    // ❌ 잘못된 이해
    {
      name: "hello"  // baseName만 저장?
    }

    실제 구조: baseName "hello"에 대해 환경별로 독립 CloudFunction 문서가 존재합니다.

    // ✅ 실제 MongoDB 구조
    // baseName "hello" → 최소 1개, 최대 3개 Function
    {
      _id: ObjectId("..."),
      name: "dev/hello",      // dev 환경
      source: { version: 1, files: {...} }
    }
    {
      _id: ObjectId("..."),
      name: "staging/hello",  // staging 환경 (Promote 후 생성)
      source: { version: 1, files: {...} }
    }
    {
      _id: ObjectId("..."),
      name: "prod/hello",     // prod 환경 (Promote 후 생성)
      source: { version: 1, files: {...} }
    }

    핵심 차이점

    항목 Before After (Environment-Agnostic)
    MongoDB 구조 dev/hello, staging/hello, prod/hello 동일 (환경별 독립 Function)
    Function 목록 조회 3개 모두 조회 dev만 조회 (66% 절감)
    API 응답 dev/hello, staging/hello, prod/hello baseName으로 변환 (hello)
    Frontend 표시 getDisplayName() 필요 baseName 그대로 표시
    URL /dev/hello (중복) /hello (Subdomain으로 환경 구분)

    왜 dev만 조회?

    • Frontend는 개발 중인 최신 버전만 보면 됨 (dev 환경)
    • staging/prod는 이미 배포된 안정 버전 (Frontend 목록에 표시 불필요)
    • 네트워크 비용 66% 감소 (3개 → 1개)

    환경별 조회가 필요한 경우:

    • History 조회: GET /functions/hello/history?environment=staging
    • 각 환경은 독립 functionId를 가지므로 environment 지정 필수

    구현 세부사항

    1. Backend: baseName 추출 및 반환

    유틸리티 함수 작성

    // server/src/utils/getter.ts
    
    /**
     * Extract baseName from CloudFunction fullName (environment-agnostic)
     * @param fullName - Function name with environment prefix
     * @returns baseName without environment prefix
     * @example
     * extractBaseName("dev/hello") // "hello"
     * extractBaseName("staging/user/me") // "user/me"
     * extractBaseName("hello") // "hello" (no prefix)
     */
    export function extractBaseName(fullName: string): string {
      if (!fullName) return fullName
      return fullName.replace(/^(dev|staging|prod)\//, '')
    }

    Function CRUD: dev 환경만 조회

    // server/src/function/function.service.ts
    
    async findAll(gatewayId: string) {
      // Only query dev/* functions to avoid network waste
      // Frontend is environment-agnostic and only needs baseName
      const res = await this.db
        .collection<CloudFunction>('CloudFunction')
        .find({
          gatewayId,
          name: { $regex: /^dev\// } // ✅ dev 환경만 조회
        })
        .toArray()
    
      return res
    }

    Before (3개 환경 조회):

    // ❌ 네트워크 낭비
    .find({ gatewayId }) // dev/hello, staging/hello, prod/hello 모두 조회

    After (dev만 조회):

    // ✅ 66% 네트워크 절감
    .find({ gatewayId, name: { $regex: /^dev\// } }) // dev/hello만 조회

    Controller: baseName 변환

    // server/src/function/function.controller.ts
    
    @Get()
    async findAll(@Param('gatewayId') gatewayId: string) {
      const data = await this.functionsService.findAll(gatewayId)
    
      // Transform all function names to baseName (environment-agnostic)
      const transformed = data.map(func => ({
        ...func,
        name: extractBaseName(func.name) // dev/hello → hello
      }))
    
      return ResponseUtil.ok(transformed)
    }

    Function CRUD: baseName 입력 자동 처리

    // server/src/function/function.controller.ts
    
    @Post()
    async create(
      @Param('gatewayId') gatewayId: string,
      @Body() dto: CreateFunctionDto,
    ) {
      // Auto-add dev/ prefix if not already present (support baseName input)
      const fullName = dto.name.includes('/') ? dto.name : `dev/${dto.name}`
    
      const res = await this.functionsService.create(gatewayId, fullName, dto)
    
      // Return baseName to Frontend
      return ResponseUtil.ok({ ...res, name: extractBaseName(res.name) })
    }

    Frontend에서 "hello" 입력 → Backend가 "dev/hello"로 자동 변환

    GET by baseName: 환경 자동 추가

    // server/src/function/function.controller.ts
    
    @Get(':baseName')
    async findOne(
      @Param('gatewayId') gatewayId: string,
      @Param('baseName') baseName: string,
    ) {
      // Auto-add dev/ prefix for baseName lookup
      const fullName = baseName.includes('/') ? baseName : `dev/${baseName}`
    
      const func = await this.functionsService.findOne(gatewayId, fullName)
    
      if (!func) {
        throw new NotFoundException('Function not found')
      }
    
      // Return baseName to Frontend
      return ResponseUtil.ok({ ...func, name: extractBaseName(func.name) })
    }

    2. Frontend: 환경 정보 제거

    Before: getDisplayName() 중복

    // ❌ 9개 파일에 중복
    function getDisplayName(fullName: string): string {
      return fullName.replace(/^(dev|staging|prod)\//, '')
    }
    
    // FunctionCard.tsx
    <CardTitle>{getDisplayName(func.name)}</CardTitle>
    
    // FunctionEditor.tsx
    <h1>{getDisplayName(func.name)}</h1>
    
    // ... 7개 파일 더

    After: baseName 직접 사용

    // ✅ 변환 불필요
    <CardTitle>{func.name}</CardTitle> // 서버가 이미 baseName 반환

    Environment URL 생성 (Subdomain 기반)

    // frontend/src/components/editor/hooks/useEnvironmentUrls.ts
    
    export function useEnvironmentUrls({
      gateway,
      gatewayId,
      functionName, // baseName (예: "hello")
    }: UseEnvironmentUrlsOptions): EnvironmentUrl[] {
      return useMemo(() => {
        // Environment is determined by subdomain, NOT by URL path
        const devDomain = gateway?.domain?.devDomain || `${gatewayId}.dev.api.imprun.dev`;
        const stagingDomain = gateway?.domain?.stagingDomain || `${gatewayId}.staging.api.imprun.dev`;
        const prodDomain = gateway?.domain?.prodDomain || `${gatewayId}.prod.api.imprun.dev`;
    
        return [
          {
            name: "dev",
            label: "개발",
            url: `https://${devDomain}/${functionName}`, // /hello (NOT /dev/hello)
          },
          {
            name: "staging",
            label: "스테이징",
            url: `https://${stagingDomain}/${functionName}`,
          },
          {
            name: "production",
            label: "운영",
            url: `https://${prodDomain}/${functionName}`,
          },
        ];
      }, [gateway, gatewayId, functionName]);
    }

    환경은 Subdomain으로 구분, URL path에는 baseName만 사용

    3. Infrastructure: Subdomain 기반 라우팅

    APISIX Ingress 설정

    # k8s/templates/apisix-routes/gateway-routes.yaml
    
    apiVersion: apisix.apache.org/v2
    kind: ApisixRoute
    metadata:
      name: "{{ $gatewayId }}-dev-route"
    spec:
      http:
        - name: dev-environment
          match:
            hosts:
              - "{{ $gatewayId }}.dev.api.imprun.dev"  # Subdomain으로 환경 구분
            paths:
              - "/*"  # baseName만 허용 (예: /hello)
          backends:
            - serviceName: "{{ $gatewayId }}-runtime"
              servicePort: 8080
          plugins:
            - name: proxy-rewrite
              enable: true
              config:
                regex_uri:
                  - "^/(.*)"
                  - "/dev/$1"  # ✅ dev/ prefix 자동 추가

    요청 흐름:

    https://gateway.dev.api.imprun.dev/hello
             ↓ (APISIX Ingress)
    Runtime: /dev/hello 실행

    API Directory: prod 필터링 최적화

    문제

    API Directory는 공개 API 카탈로그입니다. 모든 환경(dev/staging/prod)의 함수를 표시할 필요가 없습니다.

    해결책

    prod 환경에 배포된 안정적인 API만 표시

    Backend 필터링

    // server/src/function/function.service.ts
    
    async getFunctionsDirectory(
      search?: string,
      page: number = 1,
      pageSize: number = 20,
    ) {
      const db = SystemDatabase.db
    
      const matchStage: any = {}
    
      // FILTER: Only show public functions in directory
      matchStage.isPublic = true
    
      // FILTER: Only show prod/* functions (stable, production-ready APIs)
      matchStage.name = { $regex: /^prod\// }
    
      if (search) {
        matchStage.$text = { $search: search }
      }
    
      const result = await db
        .collection<CloudFunction>('CloudFunction')
        .aggregate([
          { $match: matchStage },
          // ... pagination, lookup
        ])
        .toArray()
    
      // Transform to baseName
      const list = functions.map((fn: any) => ({
        _id: fn._id.toHexString(),
        gatewayId: fn.gatewayId,
        gatewayName: fn.gatewayName,
        name: extractBaseName(fn.name), // prod/hello → hello
        description: fn.desc,
        tags: fn.tags,
        docs,
        createdAt: fn.createdAt,
        updatedAt: fn.updatedAt,
      }))
    
      return { list, total, page, pageSize }
    }

    Frontend: Gateway 필터 제거

    Before (Gateway 선택 드롭다운):

    // ❌ 불필요한 필터
    const [selectedGateway, setSelectedGateway] = useState<string>('all')
    
    <Select value={selectedGateway} onValueChange={setSelectedGateway}>
      <SelectItem value="all">전체 Gateway</SelectItem>
      {gateways.map((gateway) => (
        <SelectItem key={gateway.id} value={gateway.id}>
          {gateway.name}
        </SelectItem>
      ))}
    </Select>

    After (검색만 유지):

    // ✅ 간결한 UI
    const [searchQuery, setSearchQuery] = useState('')
    
    <Input
      placeholder="API 검색... (예: getTax, payment)"
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
    />

    모든 Gateway의 공개 API를 한 번에 탐색


    성능 최적화 결과

    네트워크 비용 감소

    항목 Before After 개선율
    Function 목록 조회 3개 환경 (dev/staging/prod) 1개 환경 (dev) 66% 감소
    API Directory 조회 모든 환경 prod만 66% 감소
    응답 크기 dev/hello (10 bytes) hello (5 bytes) 50% 감소

    코드 복잡도 감소

    항목 Before After 개선율
    getDisplayName() 함수 9개 파일에 중복 0개 (제거) 100% 제거
    Frontend 환경 처리 로직 각 컴포넌트마다 처리 0개 (제거) 100% 제거
    URL 생성 복잡도 환경 + baseName 결합 baseName만 사용 간결화

    마무리

    핵심 요약

    Environment-Agnostic Architecture는 Frontend가 환경을 몰라도 되도록 설계하는 패턴입니다.

    핵심 원칙:

    1. Frontend는 baseName만 사용
    2. Backend는 환경 prefix 자동 처리
    3. Infrastructure는 Subdomain으로 환경 구분
    4. API 응답은 항상 baseName 반환

    언제 사용하나?

    Environment-Agnostic Pattern 권장:

    • ✅ Multi-environment 배포 (dev/staging/prod)
    • ✅ Frontend 코드 간결화 필요
    • ✅ 네트워크 비용 최적화 필요
    • ✅ 환경 변경이 잦은 프로젝트

    Environment-Aware Pattern 권장:

    • ✅ 환경별로 완전히 다른 로직 필요
    • ✅ 단일 환경만 존재
    • ✅ Frontend에서 환경별 UI 차이 필요

    실제 적용 결과

    imprun.dev 환경:

    • ✅ 네트워크 비용 66% 감소 (3개 → 1개 환경 조회)
    • ✅ Frontend 9개 파일에서 getDisplayName() 제거
    • ✅ API 응답 크기 50% 감소 (환경 prefix 제거)
    • ✅ Subdomain 기반 라우팅으로 인프라 단순화

    운영 경험:

    • 적용 시간: 2시간 (Backend + Frontend + Infrastructure)
    • 배포 영향: 없음 (Backward Compatible)
    • 유지보수성: 매우 높음 😊

    참고 자료

    관련 글


    태그: Architecture, EnvironmentAgnostic, APIDesign, Frontend, Backend, Kubernetes, imprun


    "Frontend는 환경을 몰라야 한다. 환경은 인프라의 책임이다."

    🤖 이 블로그는 실제 프로덕션 환경에서 Environment-Agnostic Architecture를 운영한 경험을 바탕으로 작성되었습니다.


    질문이나 피드백은 블로그 댓글에 남겨주세요!

Designed by Tistory.