-
Environment-Agnostic Architecture 구현기: "baseName만 저장한다"는 착각에서 벗어나기실제 경험과 인사이트를 AI와 함께 정리한 글 2025. 11. 3. 16:29
작성일: 2025-11-03
카테고리: Implementation, Troubleshooting, Architecture
난이도: 중급
TL;DR
- 문제: "Environment-Agnostic = MongoDB에 baseName만 저장"이라는 착각으로 404 에러 폭탄
- 해결: 환경별 독립 CloudFunction 존재 (
dev/hello,staging/hello,prod/hello),extractBaseName()패턴 전면 적용 - 핵심: "Frontend는 환경 몰라도 됨" ≠ "MongoDB도 환경 몰라도 됨"
- 결과: Function 생성 실패, History 404, Promote 실패 → 모두 해결 (4시간 디버깅)
들어가며
imprun.dev는 "API 개발부터 AI 통합까지, 모든 것을 하나로 제공"하는 Kubernetes 기반 API 플랫폼입니다.
Environment-Agnostic Architecture (1탄)에서 이론적인 설계를 공유했습니다. "Frontend는 환경을 모른다"는 아름다운 원칙이었죠.
그런데 실제 구현 과정은 지옥이었습니다.
우리가 마주한 질문:
- ❓ POST로 "hello" 생성 성공 → GET으로 목록 조회하면
[]빈 배열? - ❓ Frontend에 "dev/234523452345" 표시 → MongoDB에
dev/dev/234523452345저장됨? - ❓ History 조회 404 에러 → "environment 파라미터는 왜 필요한데?"
시행착오 과정:
"baseName만 저장한다"고 착각
- ✅ 이론: Frontend는 baseName만 사용
- ❌ 현실: MongoDB 조회 실패, History 404, Promote 불가능
"환경은 Subdomain으로만 구분"이라 착각
- ✅ 이론: URL은 Subdomain으로 환경 구분
- ❌ 현실: MongoDB에 환경별 독립 Function 존재
"extractBaseName() 몇 곳만 적용하면 됨"이라 착각 ← 최종 깨달음
- ✅ 현실: Controller의 모든 메서드에 패턴 적용 필요
- ✅ 현실: MongoDB는
dev/hello,staging/hello,prod/hello각각 독립 저장 - ✅ 현실: History는 environment 쿼리 필수 (각 환경별 독립 functionId)
결론:
- ✅ MongoDB 이중 prefix 데이터 정리 (마이그레이션 스크립트)
- ✅ Controller 전체 메서드에
extractBaseName()패턴 적용 - ✅ History 엔드포인트에 environment 쿼리 파라미터 추가
이 글은 imprun.dev 플랫폼 구축 경험을 바탕으로, Environment-Agnostic Architecture를 구현하면서 겪은 모든 시행착오를 솔직하게 공유합니다.
1. 참사의 시작: "dev/dev/234523452345"
증상
Frontend에 이렇게 표시되고 있었습니다:
API 경로: dev/234523452345"이상한데? Environment-Agnostic인데 왜 dev/가 붙어있지?"
MongoDB를 확인했습니다:
// mongosh db.CloudFunction.findOne({ name: /234523452345/ }) // 결과 { _id: ObjectId("..."), name: "dev/dev/234523452345" // ← 이중 prefix! }원인 분석
FunctionService.create()가 무조건
dev/prefix를 추가하고 있었습니다:// ❌ 문제 코드 async create(gatewayId: string, dto: CreateFunctionDto) { const devFunctionName = `dev/${dto.name}` // 입력이 이미 "dev/hello"면? await this.db.collection('CloudFunction').insertOne({ gatewayId, name: devFunctionName, // "dev/dev/hello" 저장! // ... }) }Frontend에서 이미 "dev/hello"를 보내면 → MongoDB에
dev/dev/hello저장!해결
extractBaseName()로 먼저 정리:
// ✅ 수정된 코드 import { extractBaseName } from '@/utils/getter' async create(gatewayId: string, dto: CreateFunctionDto) { // 1. 입력에서 환경 prefix 제거 (있으면) const baseName = extractBaseName(dto.name) // "dev/hello" → "hello" // 2. dev/ prefix 추가 const devFunctionName = `dev/${baseName}` // "dev/hello" await this.db.collection('CloudFunction').insertOne({ gatewayId, name: devFunctionName, // ... }) }데이터 정리 (마이그레이션 스크립트)
기존 데이터에 이미 이중 prefix가 있었습니다:
// server/scripts/fix-function-names.js const { MongoClient } = require('mongodb') async function fixFunctionNames() { const client = new MongoClient(process.env.DATABASE_URL) await client.connect() const db = client.db('sys_db') const collection = db.collection('CloudFunction') const functions = await collection.find({}).toArray() let updated = 0 for (const fn of functions) { const originalName = fn.name // dev/dev/hello → hello let baseName = originalName.replace(/^(dev|staging|prod)\//, '') baseName = baseName.replace(/^(dev|staging|prod)\//, '') // 이중 제거 if (baseName !== originalName) { await collection.updateOne( { _id: fn._id }, { $set: { name: baseName } } ) console.log(`Updated: ${originalName} → ${baseName}`) updated++ } } console.log(`Total updated: ${updated}`) await client.close() } fixFunctionNames().catch(console.error)실행 결과:
Updated: dev/dev/234523452345 → 234523452345 Total updated: 1
2. Function 생성 후 목록 조회 실패
증상
1. POST /v1/api-gateways/ueigjz/functions Body: { name: "1" } Response: 200 OK 2. GET /v1/api-gateways/ueigjz/functions Response: { data: [] } // ← 빈 배열! 3. GET /v1/api-gateways/ueigjz/functions/1 Response: 404 Not Found // ← Function이 없다고?원인 분석
FunctionController.findOne()이 baseName을 그대로 조회:
// ❌ 문제 코드 @Get(':baseName') async findOne(@Param('baseName') baseName: string) { // baseName = "1" const fullName = baseName.includes('/') ? baseName : `dev/${baseName}` // fullName = "dev/1" const func = await this.functionsService.findOne(gatewayId, fullName) // MongoDB 조회: name = "dev/1" ← 존재함! if (!func) { throw new NotFoundException() } return ResponseUtil.ok(func) // ← name: "dev/1" 그대로 반환 }문제:
extractBaseName()을 응답 시에만 적용해야 하는데, 아예 적용 안 함!해결
모든 Controller 메서드에 extractBaseName() 패턴 적용:
GET /functions/:baseName
// ✅ 수정된 코드 import { extractBaseName } from '@/utils/getter' @Get(':baseName') async findOne( @Param('gatewayId') gatewayId: string, @Param('baseName') baseName: string, ) { // 1. baseName 정리 (혹시 모를 prefix 제거) const cleanBaseName = extractBaseName(baseName) // "dev/1" → "1" // 2. dev/ prefix 추가 const fullName = `dev/${cleanBaseName}` // "dev/1" // 3. MongoDB 조회 const func = await this.functionsService.findOne(gatewayId, fullName) if (!func) { throw new NotFoundException(`Function not found: ${cleanBaseName}`) } // 4. ✅ 응답 시 baseName 변환 return ResponseUtil.ok({ ...func, name: extractBaseName(func.name) // "dev/1" → "1" }) }PATCH /functions/:baseName
// ✅ update, remove, typeCheck, getOpenAPISpec 모두 동일 패턴 @Patch(':baseName') async update( @Param('baseName') baseName: string, @Body() dto: UpdateFunctionDto, ) { const cleanBaseName = extractBaseName(baseName) const fullName = `dev/${cleanBaseName}` const func = await this.functionsService.findOne(gatewayId, fullName) if (!func) { throw new NotFoundException(`Function not found: ${cleanBaseName}`) } const updated = await this.functionsService.updateOne(func, dto) return ResponseUtil.ok({ ...updated, name: extractBaseName(updated.name) // ✅ baseName 반환 }) }적용 메서드 (6곳):
findOne()- GET /functions/:baseNameupdate()- PATCH /functions/:baseNameupdateDebug()- PATCH /functions/:baseName/debugremove()- DELETE /functions/:baseNametypeCheck()- POST /functions/:baseName/typecheckgetOpenAPISpec()- GET /functions/:baseName/openapi
교훈
"Controller의 모든 메서드에서 입력 정리 + 응답 변환 패턴 적용"
3. History 조회 404 에러
증상
GET /v1/api-gateways/ueigjz/functions/1/history?environment=dev Response: 404 Not Found초기 오해
저는 이렇게 생각했습니다:
"Environment-Agnostic이니까 MongoDB에 baseName만 저장하겠지?"
// ❌ 잘못된 이해 { name: "hello" // baseName만 저장? }그래서 History 엔드포인트를 이렇게 구현:
// ❌ 잘못된 구현 @Get(':baseName/history') async getHistory(@Param('baseName') baseName: string) { // baseName으로만 조회 const func = await this.functionsService.findOne(gatewayId, baseName) // MongoDB 조회: name = "1" ← 존재하지 않음! if (!func) { throw new NotFoundException() // ← 404! } }사용자의 설명
"히스토리는 staging, prod 도 조회해야 하기 때문에 쿼리스트링으로 환경정보를 제공했다."
아하! environment 쿼리 파라미터가 필요한 이유:
- baseName "hello"에 대해 환경별로 독립 CloudFunction이 존재
dev/hello,staging/hello,prod/hello각각 다른 functionId- History는 functionId로 조회하므로 어떤 환경인지 알아야 함
최종 이해
MongoDB 실제 구조:
// ✅ 실제 MongoDB // baseName "hello" → 최대 3개 Function { _id: ObjectId("aaa"), name: "dev/hello", source: { version: 1 } } { _id: ObjectId("bbb"), name: "staging/hello", source: { version: 1 } } { _id: ObjectId("ccc"), name: "prod/hello", source: { version: 1 } }각 환경은 독립적인 functionId를 가짐!
해결
// ✅ 올바른 구현 @Get(':baseName/history') async getHistory( @Param('gatewayId') gatewayId: string, @Param('baseName') baseName: string, @Query('environment') environment?: string, // ✅ 환경 쿼리 추가 ) { // ⚠️ 중요: 환경별로 독립 CloudFunction 존재 const env = environment || 'dev' const fullName = `${env}/${baseName}` // "dev/hello", "staging/hello", "prod/hello" const func = await this.functionsService.findOne(gatewayId, fullName) if (!func) { throw new NotFoundException( `Function not found in ${env} environment: ${baseName}` ) } // History는 functionId로 조회 (각 환경별로 독립) const history = await this.functionsService.getHistory(func) return ResponseUtil.ok(history) }사용자 확인
"수정한 코드가 맞아, 다만 'staging, prod에 매칭되는 function Id는 존재하지 않습니다'는 틀렸어. 있긴 하지만 frontend 에서 알 필요가 없는 것이지."
핵심 깨달음:
- functionId는 각 환경별로 존재함
- Frontend는 baseName만 알면 됨
- Backend가 environment 쿼리로 적절한 Function 조회
4. Promote 실패: Semantic Version 제한 에러
증상
FunctionEditor에서 "배포하기" 클릭 시:
Error: Semantic version can only be set in dev environment원인 분석
FunctionService.updateOne()이 환경 추출 시도:
// ❌ 문제 코드 async updateOne(func: CloudFunction, dto: UpdateFunctionDto) { // Environment 추출 시도 const stage = func.name.split('/')[0] // "dev" const isDevEnvironment = stage === 'dev' // Semantic version은 dev만 허용? if (dto.semver && !isDevEnvironment) { throw new Error('Semantic version can only be set in dev environment') } // ... }문제: Environment-Agnostic인데 환경 기반 제약을 두고 있음!
해결
// ✅ 수정된 코드 async updateOne(func: CloudFunction, dto: UpdateFunctionDto) { // Environment-Agnostic: baseName만 사용, stage 정보 없음 const stage = 'dev' // Fixed for bundling purposes // ✅ Semantic version 제한 제거 // 모든 환경에서 semver 허용 // Bundling 로직 const bundled = await this.bundler.bundle({ files: dto.files || func.source.files, entrypoint: dto.entrypoint || func.source.entrypoint, stage, // dev로 고정 (bundling용) }) // Update await this.db.collection('CloudFunction').updateOne( { _id: func._id }, { $set: { 'source.files': bundled.files, 'source.semver': dto.semver || func.source.semver, // ... } } ) }
5. Frontend Pipeline 에러
증상
PromoteFunctionSheet.tsx:71 Uncaught ReferenceError: isPipelineEnabled is not definedPromoteFunctionSheet.tsx:185 Uncaught ReferenceError: currentStage is not defined원인
Environment-Agnostic 마이그레이션 과정에서 Pipeline 관련 코드를 삭제했는데, 일부 참조가 남아있었습니다.
해결
모든 Pipeline 변수 제거:
// ❌ 삭제된 코드 const isPipelineEnabled = gateway?.pipeline?.enabled const currentStage = devFunction?.name.split("/")[0] || "dev" const pipelineStages = [...getStageStatus()] // ✅ 수정된 코드 const activeStages = stages.filter((stage) => stage.state === "Active") const availableStages = activeStages // 모든 활성 환경으로 배포 가능targetFunction 로직 단순화:
// ❌ 삭제된 코드 const targetName = `${selectedStage}/${functionName}` const targetFunction = allFunctions.find((f) => f.name === targetName) // ✅ 수정된 코드 // Environment-Agnostic: baseName으로 찾기 const targetFunction = allFunctions.find((f) => f.name === functionName)
6. 실전 체크리스트
Backend 변경 사항
필수 패턴 (모든 Controller 메서드):
import { extractBaseName } from '@/utils/getter' // 1. 입력 파라미터 정리 const cleanBaseName = extractBaseName(baseName) // 2. dev/ prefix 추가 (또는 environment 기반) const fullName = `dev/${cleanBaseName}` // 3. MongoDB 조회 const func = await this.functionsService.findOne(gatewayId, fullName) // 4. 응답 시 baseName 변환 return ResponseUtil.ok({ ...func, name: extractBaseName(func.name) })적용 메서드 목록:
-
create()- POST /functions -
findOne()- GET /functions/:baseName -
update()- PATCH /functions/:baseName -
updateDebug()- PATCH /functions/:baseName/debug -
remove()- DELETE /functions/:baseName -
typeCheck()- POST /functions/:baseName/typecheck -
getOpenAPISpec()- GET /functions/:baseName/openapi -
getHistory()- GET /functions/:baseName/history (environment 쿼리 추가)
Frontend 변경 사항
제거된 코드:
-
getDisplayName()함수 (9개 파일에서 제거) - Pipeline 관련 변수 (
isPipelineEnabled,currentStage,pipelineStages) - 환경 prefix 제거 로직
추가된 코드:
- History 조회 시 environment 파라미터 전달
- Promote 시 source environment 포함 전송
마무리
핵심 요약
"Frontend는 환경 몰라도 됨" ≠ "MongoDB도 환경 몰라도 됨"
실전에서 배운 4가지:
- MongoDB 구조 확인이 최우선: baseName당 환경별 독립 CloudFunction 존재
- 패턴의 일관성: Controller 모든 메서드에서 extractBaseName 패턴 적용
- 데이터 마이그레이션: 기존 이중 prefix 데이터 정리 필수
- 점진적 제거: Pipeline 등 삭제된 기능의 참조 완전 제거
언제 사용하나?
Environment-Agnostic Architecture 구현 시 필수 체크:
- ✅ MongoDB 구조를 정확히 이해했는가?
- ✅ Controller 모든 메서드에 패턴 적용했는가?
- ✅ 기존 데이터 마이그레이션은 필요한가?
- ✅ 환경별 조회 엔드포인트에 environment 쿼리 추가했는가?
실제 적용 결과
imprun.dev 환경:
- ✅ 데이터 정리: 이중 prefix Function 1개 수정
- ✅ Controller: 8개 메서드 패턴 적용
- ✅ Frontend: Pipeline 변수 완전 제거
- ✅ History: environment 쿼리로 환경별 조회 가능
운영 경험:
- 디버깅 시간: 약 4시간 (실제 경험)
- 주요 시간 소모: MongoDB 구조 오해 해소 (2시간)
- ROI: 404 에러 완전 해소, 개발자 혼란 제거
- 만족도: 매우 높음 😊 (모든 CRUD 정상 동작)
관련 글
태그: Implementation, Troubleshooting, EnvironmentAgnostic, MongoDB, Backend, RealExperience
"이론은 아름다웠다. 하지만 현실은... MongoDB에 환경별 독립 Function이 존재한다는 걸 4시간 후에 깨달았다."
🤖 이 글은 imprun.dev 플랫폼 구축 과정에서 실제로 겪은 모든 시행착오와 디버깅 과정을 솔직하게 공유한 것입니다.
질문이나 피드백은 블로그 댓글에 남겨주세요!
'실제 경험과 인사이트를 AI와 함께 정리한 글' 카테고리의 다른 글
스크롤바로 인한 레이아웃 Shift 완벽 해결 가이드: scrollbar-gutter를 활용한 크로스 브라우저 대응 (0) 2025.11.23 NestJS + React 표준 응답과 JWT 인증 완벽 가이드: ResponseUtil, Axios, Zustand (0) 2025.11.06 Environment-Agnostic Architecture: Frontend와 Backend의 환경 분리 패턴 (0) 2025.11.03 CLAUDE.md 최적화 여정: AI가 패턴을 무시하는 이유와 해결책 (0) 2025.11.03 MongoDB Aggregation Pipeline로 N+1 문제 해결하기: $lookup과 $facet 활용 (0) 2025.11.03