
CarbonSaathi 구축하기: 인도 대도시 직장인을 위한 가시적 추론 기반 탄소 동반자
요약
인도 직장인을 위한 탄소 발자국 추적 서비스인 CarbonSaathi 구축 사례를 소개합니다. AI 에이전트의 추론 과정을 SSE(Server-Sent Events)를 통해 실시간으로 UI에 스트리밍하여 사용자에게 투명한 통찰을 제공하는 것이 핵심입니다.
핵심 포인트
- 단순 수치 추적을 넘어 AI의 추론 과정을 가시화하여 신뢰도 향상
- SSE를 활용해 에이전트의 사고 단계를 실시간 스트리밍 구현
- FastAPI, Gemini, Firestore, Cloud Run 기반의 기술 스택 활용
- 사용자 경험을 고려한 자연어 입력 및 인도 현지 맥락 반영
요약 (TL;DR)
저는 PromptWars Challenge 3를 위해 약 60시간 동안 CarbonSaathi를 구축했습니다. 이는 인도 대도시 직장인들을 위한 탄소 발자국 동반자로, 일상 활동을 평이한 영어로 기록하고 AI가 생성한 통찰(insights)을 제공합니다. 차별점은 각 통찰과 권장 사항 뒤에 숨겨진 추론 과정이 에이전트가 생성하는 동안 Server-Sent Events (SSE)를 통해 UI로 실시간 스트리밍된다는 점입니다. 즉, 결과가 나온 뒤에 덧붙여지는 것이 아닙니다. 기술 스택은 FastAPI + Gemini 2.5 Flash/Pro + Firestore + Cloud Run이며, GitHub Copilot을 통한 Claude Code로 구축되었습니다. GitHub 소스 코드.
문제점 (그리고 왜 "인식"이 "추적"이 아닌가)
사람들이 간단한 행동과 개인화된 통찰을 통해 일상적인 탄소 발자국을 추적하고 줄일 수 있도록 돕는 애플리케이션을 구축하십시오.
이 브리프에서 중요한 단어는 '추적(track)'이 아니라 '개인화된 통찰(personalized insights)'입니다. 대부분의 탄소 관련 앱들은 이미 추적 기능을 갖추고 있습니다. 차트가 있고, 계산기가 있을 수 있으며, 카테고리별 CO₂e(이산화탄소 상당량) 합계가 표시되기도 합니다. 하지만 그들에게 부족한 것은 그 숫자를 신뢰할 수 있는 이유와, 그 숫자로 인해 취해야 할 구체적인 행동입니다.
제가 설계한 페르소나는 인도 Tier-1 대도시(Bengaluru, Mumbai, Pune, Hyderabad, Delhi NCR)에 거주하는 28세 소프트웨어 엔지니어인 Riya 또는 Rahul입니다. 이들은 직접 전기 요금을 납부하고, 어떤 날은 메트로로 출퇴근하고 어떤 날은 Uber를 이용하며, 때로는 재택근무를 합니다. 이들은 기후 변화에 대해 막연하게 인식하고는 있지만 현재 아무것도 추적하지 않으며, 이를 위해 전용 앱을 열어볼 의지도 없습니다. 디자인은 여기서부터 시작됩니다: 마찰이 적은 기록 방식(양식이 아닌 평이한 영어 사용), 죄책감을 유발하지 않는 문구, 구체적이고 실행 가능한 조언, 그리고 모든 곳에 반영된 인도적 맥락입니다.
아키텍처를 형성한 프레임워크는 다음과 같습니다: "당신의 교통 수단이 이번 주 탄소 발자국의 71%를 차지합니다"라고 말하는 통찰은 '추적기'입니다. 반면, _AI가 14일간의 활동을 어떻게 분류했는지, 어떤 패턴을 포착했는지, 그리고 왜 택시 대신 메트로로 전환할 것을 제안했는지_를 볼 수 있는 통찰은 '인식'입니다. 그 차이는 바로 추론 과정이 가시적인가(visible)에 달려 있습니다.
이러한 카테고리의 거의 모든 제출물은 최종 결과물만을 보여줍니다. 저는 초기 단계에서 그 결과물을 만들어낸 추론 (reasoning) 과정을 드러내기로 결정했습니다. 그 결정이 이후의 모든 아키텍처 (architectural) 선택을 이끌었습니다.
차별점: 실시간으로 스트리밍되는 가시적인 에이전트 추론 (visible agent reasoning)
인사이트 (insights) 엔드포인트는 서버 전송 이벤트 (Server-Sent Events, SSE)를 반환합니다. 실제 모습은 다음과 같습니다:
curl -N -H "Accept: text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/insights/stream
event: phase_start
data: {"event":"phase_start","phase":"analyst"}
...
reasoning 텍스트는 모델이 생성하며 실행할 때마다 달라집니다. 프로토콜 구조 — phase_start → reasoning → phase_complete → done — 는 오케스트레이터 (orchestrator)에 의해 고정됩니다. SSE 경로에서는 이벤트 간에 80ms의 간격 (pacing)이 존재하지만, JSON 전용 Accept 헤더를 사용하면 간격 없이 통합된 페이로드 (payload)를 받게 됩니다.
각 추론 단계와 최종 인사이트/권장 사항은 agentReasoning 필드와 함께 Firestore에 저장됩니다. UI는 생성 중에는 라이브 스트림을 렌더링하고, 이후 조회 시에는 저장된 트레이스 (trace)를 불러옵니다. 따라서 이는 단순한 시각적 효과가 아니라 감사 (auditable) 가능한 데이터입니다.
위 스트림에서 코치 (Coach)의 산술 계산을 주목해 보세요: petrol cab 0.170 vs metro 0.031 kg/km. 이 숫자들은 모델의 출력이 아니라 로컬 계수 테이블 (factor table)에서 가져온 것입니다. 모델은 탄소 수치를 직접 설정하지 않습니다. 모델은 '교체 (swap)'를 제안하고, 에이전트가 배출 서비스 (emission service)로부터 해당 교체안을 검증하고 절감량을 계산합니다. 이 불변성 (invariant)은 이 프로젝트에서 가장 중요한 설계 결정 중 하나이며, 프롬프트 (prompts) 섹션에서 이것이 어떻게 도출되었는지 설명하겠습니다.
아키텍처 (Architecture)
Cloud Run 상의 비동기 (async) FastAPI 서비스 뒤에서 작동하는 세 개의 순차적인 AI 에이전트 (agents)입니다. 모든 배출량 산술 연산은 인용된 인도 계수 데이터 (Indian factor data)를 바탕으로 로컬에서 실행되며, 모든 모델 출력값은 신뢰하기 전에 검증 과정을 거칩니다.
flowchart TD
U["User browser<br/>Tailwind + vanilla JS"]
FB["Firebase Auth<br/>Google Sign-In"]
...
Firestore 데이터 모델은 모든 사용자 대상 문서에 agentReasoning을 포함하고 있으며, 이것이 "과정 보여주기 (show your work)" UI를 구동하는 핵심입니다:
users/{uid}
email, displayName, state, homeProfile{ bhk, hasAC, fridgeClass, dietary }
onboardingComplete, createdAt, lastActive
...
"오늘", "이번 주", 활동 연속 기록 (activity streak) 등 모든 사용자 대상 시간 집계는 읽기 시점에 인도 표준시 (IST, Asia/Kolkata)로 계산됩니다. 타임스탬프 (Timestamps)는 UTC로 저장됩니다. 연속 기록은 Duolingo 스타일의 당일 유예 기간을 사용합니다. 즉, 오늘 활동이 아직 없다면, 사용자가 기록을 남기기 전까지 연속 기록이 끊긴 것으로 표시되지 않도록 어제부터 역산하여 계산합니다.
에이전트 파이프라인 (The agent pipeline)
flowchart TD
START(["GET /api/insights/stream"]) --> STALE{"is_pipeline_stale?"}
STALE -->|"No — cache fresh"| CACHED["2x phase_complete status=cached<br/>+ done · zero agent calls"]
...
| 에이전트 (Agent) | 모델 (Model) | 역할 (Job) |
|---|---|---|
| Logger | Gemini 2.5 Flash | 함수 호출 (function calling)을 통해 자유 형식의 텍스트 입력을 유형화된 활동으로 파싱 |
| ... |
세 가지 설계 규칙이 파이프라인의 근간을 이룹니다:
코치(Coach)는 절감량을 계산합니다. 숫자에 대해서는 모델을 절대 신뢰하지 않습니다. 모델은 어떤 교체(swap)를 수행할지 설명하는 판별된 유니온(discriminated union) 형태의 유형화된 saving_basis를 반환합니다. 에이전트(agent)는 해당 설명을 배출 계수(emission factor) 테이블과 대조하여 검증하고, expectedSavingKg를 로컬에서 직접 계산합니다. "주당 3.2kg을 절감하세요"라고 말하는 모델은 종종 환각(hallucination)을 일으키지만, "평일 출퇴근 8km 구간에서 휘발유 택시 대신 지하철로 교체하세요"라고 말하는 모델은 에이전트가 실제 계수를 바탕으로 실제 숫자를 계산하는 데 필요한 모든 정보를 제공합니다.
모든 에이전트 결과물은 유형화된 판별된 유니온(discriminated union)입니다. LoggerOutcome, AnalystOutcome, CoachOutcome은 모두 Annotated[Union[Success, Empty, Rejected, Failed], Field(discriminator="status")] 형태입니다. 거버넌스 거부(governance rejection), 낮은 데이터 볼륨, 모델로부터의 잘못된 JSON 형식 등 예상 가능한 실패는 예외(exception)가 아닌 값(value)으로 처리됩니다. 라우트(Routes)는 status 필드를 패턴 매칭하여 HTTP 응답으로 변환합니다.
신선도 캐싱(Staleness caching)이 파이프라인을 단축합니다. 마지막 실행 이후 변경 사항이 없다면 (IST-인도 표준시 기준 일 단위 정렬, Analyst와 Coach에 대해 각각 별도의 10분 공백 결과 TTL 적용), 오케스트레이터(orchestrator)는 캐시된 결과를 반환하고 에이전트를 전혀 호출하지 않습니다. 캐시된 경로에서는 phase_complete(status="cached") 이벤트를 발생시키므로, 생성(generation) 실행 여부와 관계없이 UI 응답 형태가 일관되게 유지됩니다.
인도를 위해 구축됨: 사양 정렬 핵심(spec-alignment core)
이 지점이 대부분의 탄소 관련 앱들이 인도의 사양(spec)을 충족하지 못해 실패하는 부분입니다. 모든 숫자는 인도에 특화되어 있으며 출처가 인용됩니다.
전력 — 주 그리드 계수 (kg CO₂e/kWh, CEA CO₂ Baseline Database v19.0, 2023–24). 동일한 kWh의 전력이라도 거주하는 주의 발전 믹스(generation mix)에 따라 탄소 배출량은 매우 달라집니다:
| 주 (State) | 계수 (Factor) | 비고 (Note) |
|---|---|---|
| Sikkim | 0.38 | 수력 중심 (Teesta cascade) |
| ... |
28개 주와 8개 연방 직할지(UTs)가 계수 파일(factor file)에 포함되어 있습니다. 전기 요금을 루피(rupees) 단위로 입력하는 사용자의 경우 AVG_INR_PER_KWH = 8.0을 사용하여 변환합니다. 이는 실제 구간별(slab-based) 배전 회사(DISCOM) 요금제보다 정밀도가 낮은 단순 평균값입니다. 따라서 요금에서 유도된 모든 활동은 그리드 계수(grid factor)의 신뢰 수준과 관계없이 confidence = "estimated"로 강제 설정됩니다. 이러한 가정은 주석 속에 숨겨져 있지 않고, 명시적으로 유형화되어 문서화되었습니다.
교통 (Transport) (kg CO₂e/km — ICCT India, DMRC, India GHG Inventory):
| 수단 (Mode) | 계수 (Factor) | 수단 (Mode) | 계수 (Factor) |
|---|---|---|---|
| Metro | 0.031 | Auto-rickshaw (CNG) | 0.066 |
| ... |
걷기와 재택근무(WFH)는 정의상 0입니다. 로거(Logger) 프롬프트는 인도 도시의 교통 어휘인 오토릭샤(auto-rickshaw), 메트로(metro), 버스(bus), Uber/Ola/Rapido, 이륜차(two-wheeler), 그리고 재택근무(WFH)를 명시적으로 인식합니다.
음식 (Food) (kg CO₂e/serving — FAO Food Emissions Database + 인도 식단 조사 데이터; 쌀에는 IRRI를 통한 논 메탄(paddy-field methane)이 포함됨):
| 항목 (Item) | 계수 (Factor) | 항목 (Item) | 계수 (Factor) |
|---|---|---|---|
| Veg thali | 0.90 | Egg (1) | 0.25 |
| ... |
양고기(Mutton)는 양(sheep)이 아닌 염소(goat)를 의미하며, 이는 관련 인도 시장의 맥락을 반영한 것입니다. 식단 카테고리(채식 / 비채식 / 에그테리언)는 인도의 식습관 패턴에 맞춰 구성되었습니다. 파니르(Paneer)는 유제품과는 별도로 표시됩니다.
도구 및 선정 근거
GitHub Copilot을 통한 Claude Code. 빌드 과정 전반에 걸쳐 의도적인 모델 로테이션(model rotation)을 사용했습니다. Sonnet 4.6은 출력량이 중요하고 사양이 잘 정의된 스캐폴딩(scaffolding) 중심 단계(1A, 1C, 1D, 2, 3, 5A, 5B, 6, 7, 8, 9)를 담당했습니다. Opus 4.8은 구조적 추론이 필요한 고위험 단계(1B FastAPI 코어, 4A 베이스 에이전트 + Logger, 4B Analyst + Coach, 5C SSE 오케스트레이션, 10 README 다듬기)를 담당했습니다. 그 논리는 다음과 같습니다: Opus는 토큰당 비용이 더 높지만, 초기 결정 오류가 전체 세션에 걸쳐 누적될 수 있는 복잡한 교차 파일 추론(cross-file reasoning) 작업에서 아키텍처 오류를 더 적게 범합니다.
Gemini 2.5 Flash를 로거(Logger)로 사용: 자유 형식의 텍스트(free-text)를 타입화된 활동(typed activity)으로 파싱하기 위한 함수 호출(function calling) 용도입니다. Flash는 이 사용 사례에 충분히 빠르며, 로깅 빈도를 고려할 때 Pro보다 훨씬 저렴합니다. 코치(Coach) 에이전트 또한 Flash에서 실행됩니다. 원래는 Pro로 지정되었으나, 9단계(Phase 9) 엔드 투 엔드(end-to-end) 검증 결과 권장 품질이 수용 가능한 수준임을 확인한 후 Flash를 유지했습니다. 이에 대한 자세한 내용은 '전투 기록(war stories)' 섹션에서 다룹니다.
Gemini 2.5 Pro를 분석가(Analyst)로 사용: 14일간의 활동 창(activity window) 전반에 걸친 패턴 탐지는 Flash와 Pro 사이의 품질 차이가 비용을 지불할 만큼 일관되게 나타나는 유일한 작업입니다. 분석가는 Python에서 이미 그룹화된 사전 버킷화(pre-bucketed) 활동 데이터(this_week, last_week, earlier)를 전달받으며, 날짜 계산(date math)은 모델에 위임하지 않습니다.
FastAPI + Pydantic v2. 전체 과정에 비동기(Async)를 적용했습니다. 이는 단일 요청 경로가 여러 번의 Firestore 읽기와 Gemini 호출을 포함할 때 중요합니다. 불변 도메인 객체(immutable domain objects)를 위해 Frozen Pydantic 모델을 사용합니다. 에이전트 결과값에는 판별 가능한 유니온(Discriminated unions)을 사용하여, 오류 처리가 방어적(defensive)인 수준을 넘어 철저하게(exhaustive) 이루어지도록 하는 패턴을 적용했습니다.
Spark 무료 티어의 Firestore. Firebase Auth와 네이티브하게 통합되며(동일 프로젝트, 동일 uid 네임스페이스), 비용 부담 없이 데모 트래픽을 처리할 수 있고 관리할 서버가 없습니다. 제약 사항은 분명합니다. 이는 확장성(scale)이 아닌 데모를 위해 설계된 규모입니다.
asia-south1 지역의 Cloud Run. 미국의 어떤 리전보다 인도 사용자에게 낮은 지연 시간(latency)을 제공합니다. min-instances=1 설정을 통해 콜드 스타트(cold-start) 지연 시간을 방지하고 데모 제출 시간대에 즉시 응답할 수 있는 웜 인스턴스(warm instance)를 유지합니다.
Google 로그인을 통한 Firebase Authentication. 이번 과제에는 지속적인 사용자 데이터가 필요했습니다. Firebase는 비밀번호 데이터베이스나 JWT 서명 인프라 없이도 신원(identity)을 처리합니다.
CDN을 통한 Tailwind + vanilla ES modules, 빌드 단계 없음. 원래 사양에는 점진적 향상 (progressive enhancement)을 위해 HTMX가 포함되어 있었습니다. HTMX는 HTML 조각을 반환하는 하이퍼미디어 API (hypermedia APIs)를 위해 설계되었습니다. 즉, hx-get/hx-post 타겟이 JSON이 아닌 렌더링된 HTML을 응답할 것으로 기대합니다. 이 앱의 모든 API 엔드포인트는 JSON API입니다. HTMX를 vanilla fetch() + ES modules로 교체하면서 직접 작성한 클라이언트 코드가 추가되었지만, 이는 JSON API와 Bearer 헤더가 필요한 SSE 스트림(SSE stream) 모두와 깔끔하게 작동하는 유일한 설계였습니다.
프롬프트의 진화 과정
번호가 매겨진 계획 게이트 (The numbered-plan gate)
빌드 초기에는 Copilot에게 기능을 설명하고 Enter를 누르면 한 번에 8개의 파일이 생성되었습니다. 생성된 내용 중 일부는 맞았지만, 일부는 두 단계 뒤에나 나올 기능들을 위한 성급한 스캐폴딩 (scaffolding)이었습니다. Phase 1A 이후, 모든 프롬프트에는 다음과 같은 필수적인 마지막 지침이 추가되었습니다: "당신이 구축할 내용에 대해 번호가 매겨진 계획을 출력하고 멈추세요 (STOP). 내가 확인하기 전까지는 어떤 파일도 작성하지 마세요."
이 계획 게이트는 제가 프롬프트 워크플로우에 적용한 그 어떤 단일 변경 사항보다 노력 대비 효율(return-on-effort)이 높았습니다. 이 방식이 효과를 발휘한 두 가지 구체적인 사례가 있습니다. 첫째, 계획을 통해 Copilot이 비즈니스 로직을 에이전트 (agent)가 아닌 라우트 핸들러 (route handler) 내부에 넣으려 한다는 것을 발견했습니다 (코드가 한 줄도 작성되기 전에 포착됨). 둘째, Phase 5에서 마이그레이션 (migration)이 필요했을 Firestore 스키마 설계를 발견했습니다. 두 사례 모두 리팩터링 (refactor)이 아닌 계획 단계에서의 명확한 설명을 통해 수정되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기