최종 업데이트: 2025-11-04 버전: v1.3 - Streaming + Database Persistence
구현일: 2025-11-04
기능:
- ChatGPT 스타일의 점진적 결과 표시
- Server-Sent Events (SSE) 프로토콜 사용
- 실시간 진행률 표시 (0% → 100%)
- 한글 상태 메시지
- LLM 생성 과정 실시간 표시
파일:
services/llm-agent/src/routes/langgraph_plans.py # SSE endpoint
services/llm-agent/src/services/langgraph_planner.py # Streaming logic
mobile/src/api/streaming.ts # Mobile SSE client
mobile/src/screens/CreatePlanScreen.tsx # UI with streaming
엔드포인트:
POST http://localhost:8000/api/v1/plans/generate/stream
Content-Type: text/event-stream
이벤트 타입:
status: 현재 작업 상태 (예: "날씨 정보 수집 중...")progress: 진행률 업데이트chunk: LLM 생성 내용 조각complete: 최종 플랜 완성error: 오류 발생done: 스트림 종료
구현일: 2025-11-04
기능:
- 동일한 요청에 대한 즉시 응답 (<1초)
- 1시간 TTL (Time To Live)
- 99% 비용 절감 효과
캐시 키 형식:
plan:{location}:{startDate}:{endDate}:{budget}
예: plan:Seoul:2025-11-10:2025-11-12:medium
성능:
- 첫 생성: ~6초 (LLM 호출)
- 캐시 히트: <1초 (Redis 조회)
구현일: 2025-11-04
기능:
- PostgreSQL에 플랜 저장
- 사용자별 플랜 관리
- 앱 재시작 후에도 플랜 유지
- CRUD 전체 구현 완료
데이터베이스 스키마:
-- plan_service 스키마
travel_plans
├── id (BIGSERIAL)
├── user_id (BIGINT)
├── title (VARCHAR)
├── start_date (DATE)
├── end_date (DATE)
├── budget_level (VARCHAR) -- low/medium/high
├── status (VARCHAR) -- DRAFT/CONFIRMED/COMPLETED
├── created_at (TIMESTAMP)
└── updated_at (TIMESTAMP)
plan_details
├── id (BIGSERIAL)
├── plan_id (BIGINT FK)
├── day (INTEGER)
├── location (VARCHAR)
├── activity (TEXT)
└── created_at (TIMESTAMP)구현 파일:
services/plan-service/src/main/java/com/oddiya/plan/
├── entity/
│ ├── TravelPlan.java
│ └── PlanDetail.java
├── repository/
│ ├── TravelPlanRepository.java
│ └── PlanDetailRepository.java
└── service/
└── PlanService.java (save/fetch logic)
구현일: 2025-11-04
기능:
- 스트리밍 진행률 표시
- 실시간 LLM 출력 표시
- 타이머 (생성 시간 측정)
- 캐시 여부 표시 (배지)
- 자동 데이터베이스 저장
- Plans 리스트 자동 새로고침
UI 요소:
- 입력 폼: 목적지, 시작일, 종료일, 예산
- 진행률 바: 0% → 100%
- 상태 메시지: 한글 업데이트
- AI 출력: LLM 청크 실시간 표시
- 플랜 미리보기: 완성 후 표시
- 배지: "✨ Newly Generated" vs "💾 Cached"
타이머 버그 (2025-11-04):
// Before: 항상 0.0s 표시
const timer = setInterval(() => {
setElapsedTime((Date.now() - Date.now()) / 1000); // BUG!
}, 100);
// After: 실제 경과 시간 표시
const startTimestamp = Date.now();
const timer = setInterval(() => {
setElapsedTime((Date.now() - startTimestamp) / 1000);
}, 100);Mobile App (React Native 0.75)
↓ HTTP/SSE
API Gateway (8080) ← 일반 REST API
↓
┌───────────────────┬────────────────────┬─────────────────┐
│ Auth Service │ User Service │ Plan Service │
│ (8081) │ (8082) │ (8083) │
│ - OAuth 2.0 │ - User CRUD │ - Plan CRUD │
│ - JWT RS256 │ - Profile │ - DB persistence│
└───────────────────┴────────────────────┴─────────────────┘
↓
LLM Agent (8000)
- FastAPI
- LangChain
- LangGraph
- Gemini API
↓
Google Gemini
(gemini-2.0-flash-exp)
Mobile App → LLM Agent (8000) ← 스트리밍 직접 연결
↓ SSE
LLM Agent streams back
중요: 모바일 앱은 스트리밍을 위해 LLM Agent에 직접 연결합니다.
// mobile/src/api/streaming.ts
const llmAgentUrl = BASE_URL.replace('8080', '8000');
// http://localhost:8080 → http://localhost:8000이유:
- API Gateway는 SSE 프록시 지원 제한적
- 실시간 스트리밍 성능 최적화
- 단순한 구조
[모바일 앱]
1. 사용자가 폼 입력 (Seoul, 2025-11-10, 2025-11-12, Medium)
2. "Generate Travel Plan" 버튼 탭
3. generatePlanStreaming() 호출
↓
[LLM Agent - langgraph_plans.py]
4. POST /api/v1/plans/generate/stream 수신
5. Redis 캐시 확인 (cache_key = "plan:Seoul:...")
6. 캐시 없음 → LLM 생성 시작
↓
[LangGraph Planner]
7. Step 1: 날씨 정보 수집 (10%)
→ yield {'type': 'status', 'message': '날씨 정보 수집 중...'}
8. Step 2: AI 플랜 생성 (20-60%)
→ yield {'type': 'chunk', 'content': 'Morning: 경복궁...'}
9. Step 3-5: 검증/개선/완성 (60-100%)
→ yield {'type': 'progress', 'progress': 70}
10. Final: 플랜 완성
→ yield {'type': 'complete', 'plan': {...}}
11. Redis에 캐시 저장 (TTL: 3600초)
→ yield {'type': 'done'}
↓
[모바일 앱 - CreatePlanScreen]
12. onStatus: 상태 메시지 업데이트
13. onProgress: 진행률 바 업데이트
14. onChunk: LLM 출력 표시
15. onComplete: 플랜 미리보기 표시
↓
[Plan Service - PlanService.java]
16. dispatch(createPlan({...})) 호출
17. POST /api/v1/plans
18. LLM Agent 호출 (비스트리밍 엔드포인트)
19. 응답 받은 후 PostgreSQL에 저장
→ TravelPlan entity + PlanDetail entities
20. 저장 완료 로그: "✅ Plan saved to database: id=1"
↓
[모바일 앱]
21. dispatch(fetchPlans()) 호출
22. GET /api/v1/plans (X-User-Id: 1)
23. Plans 리스트 새로고침
24. 새 플랜이 리스트에 표시됨
[모바일 앱]
1-3. (동일)
↓
[LLM Agent]
4-5. (동일)
6. 캐시 있음! (Redis에서 조회)
7. yield {'type': 'status', 'message': '💾 저장된 계획...', 'cached': true}
8. yield {'type': 'complete', 'plan': {...}, 'cached': true}
9. yield {'type': 'done'}
↓
[모바일 앱]
10. 상태 박스 녹색으로 변경
11. 배지: "💾 Cached" 표시
12. 타이머: <1초
13. 데이터베이스 저장 스킵 (cached=true)
[모바일 앱]
1. Plans 탭 진입 또는 Pull-to-refresh
2. dispatch(fetchPlans()) 호출
↓
[Plan Service]
3. GET /api/v1/plans (Header: X-User-Id: 1)
4. TravelPlanRepository.findByUserIdOrderByCreatedAtDesc(1)
5. PostgreSQL 쿼리:
SELECT * FROM plan_service.travel_plans
WHERE user_id = 1
ORDER BY created_at DESC
6. 결과를 PlanResponse DTO로 변환
7. JSON 응답 반환
↓
[모바일 앱]
8. Redux state 업데이트
9. PlansScreen 리렌더링
10. 플랜 리스트 표시
# Google Gemini AI (필수)
GOOGLE_API_KEY=AIzaSyDlMvCLaGNMbPJXvnNkpjf_d4gOQOr5Hbk
GEMINI_MODEL=gemini-2.0-flash-exp
# Redis (필수 - 캐싱용)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_CACHE_TTL=3600 # 1시간
# PostgreSQL (Plan Service용)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=oddiya
DB_USER=admin
DB_PASSWORD=4321spring:
datasource:
url: jdbc:postgresql://localhost:5432/oddiya?currentSchema=plan_service
username: admin
password: 4321
hikari:
maximum-pool-size: 5
llm:
agent:
base-url: http://localhost:8000export const API_CONFIG = {
LOCAL_SIMULATOR: 'http://localhost:8080', // API Gateway
// Streaming은 8000 포트로 직접 연결
};
export const BASE_URL = getBaseURL();
// iOS Simulator: http://localhost:8080
// Android Emulator: http://10.0.2.2:8080# 확인 명령어
ps aux | grep -E "python.*main.py|java.*plan-service|redis-server"
# 1. LLM Agent (Python FastAPI)
# Port: 8000
# PID: 89540
# 로그: /tmp/llm-agent.log
python main.py
# 2. Plan Service (Spring Boot)
# Port: 8083
# PID: 10107
# 로그: /tmp/plan-service.log
java -jar build/libs/plan-service-0.1.0.jar
# 3. Redis
# Port: 6379
# 확인: redis-cli ping → PONG
brew services start redis
# 4. PostgreSQL
# Port: 5432
# Database: oddiya
# Schemas: plan_service, user_service, auth_service
# 확인: PGPASSWORD=4321 psql -h localhost -U admin -d oddiya# Health checks
curl http://localhost:8000/health # LLM Agent
curl http://localhost:8083/actuator/health # Plan Service
redis-cli ping # Redis
pg_isready -h localhost -U admin # PostgreSQL
# 데이터베이스 확인
PGPASSWORD=4321 psql -h localhost -U admin -d oddiya -c \
"SELECT COUNT(*) FROM plan_service.travel_plans;"
# Redis 캐시 확인
redis-cli keys "plan:*"
redis-cli GET "plan:Seoul:2025-11-10:2025-11-12:medium"App.tsx
└── AppNavigator
├── WelcomeScreen (로그인/가입)
└── MainTabs
├── PlansScreen (플랜 목록)
│ └── → CreatePlanScreen (새 플랜 생성)
│ └── → PlanDetailScreen (플랜 상세)
├── ExploreScreen
├── BookmarksScreen
└── ProfileScreen
mobile/
├── src/
│ ├── api/
│ │ ├── services.ts # REST API 호출
│ │ └── streaming.ts # SSE 스트리밍 (NEW)
│ ├── screens/
│ │ ├── WelcomeScreen.tsx
│ │ ├── PlansScreen.tsx
│ │ └── CreatePlanScreen.tsx # 스트리밍 UI (NEW)
│ ├── store/
│ │ └── slices/
│ │ ├── authSlice.ts
│ │ └── plansSlice.ts # 플랜 상태 관리
│ ├── navigation/
│ │ ├── AppNavigator.tsx # CreatePlan 등록 (UPDATED)
│ │ └── types.ts # 네비게이션 타입 (UPDATED)
│ ├── types/
│ │ └── index.ts # TravelPlan 타입 (UPDATED)
│ └── constants/
│ └── config.ts # API 설정
└── package.json # native-stack 추가 (UPDATED)
// authSlice
{
isAuthenticated: boolean,
user: User | null,
accessToken: string | null,
refreshToken: string | null
}
// plansSlice
{
plans: TravelPlan[], // 데이터베이스에서 조회한 플랜 목록
loading: boolean,
error: string | null
}// services.ts (REST API)
POST /api/v1/plans # 플랜 저장
GET /api/v1/plans # 플랜 목록 조회
GET /api/v1/plans/:id # 플랜 상세 조회
PATCH /api/v1/plans/:id # 플랜 수정
DELETE /api/v1/plans/:id # 플랜 삭제
// streaming.ts (SSE)
POST http://localhost:8000/api/v1/plans/generate/stream # 스트리밍 생성// mobile/src/types/index.ts
interface TravelPlan {
id?: number;
userId?: number;
title: string;
startDate?: string;
endDate?: string;
days?: PlanDay[]; // LLM에서 생성
totalEstimatedCost?: number; // LLM에서 계산
weatherSummary?: string; // LLM에서 생성
tips?: string[]; // LLM에서 생성
metadata?: PlanMetadata;
createdAt?: string;
updatedAt?: string;
}
interface PlanDay {
day: number;
location: string;
activity: string;
estimatedCost: number;
weatherTip?: string;
}// mobile/src/screens/CreatePlanScreen.tsx
const budgetMap = {
low: 50000, // ₩50,000/day
medium: 100000, // ₩100,000/day
high: 200000, // ₩200,000/day
};
// 예: 3일 medium budget
// totalBudget = 100000 * 3 = 300000# 1. 백엔드 서비스 시작
cd /Users/wjs/cursor/oddiya/services/llm-agent
source venv/bin/activate
python main.py # Port 8000
cd /Users/wjs/cursor/oddiya/services/plan-service
./gradlew bootRun # Port 8083
# 2. 데이터베이스 & 캐시 시작
brew services start redis
brew services start postgresql
# 3. 모바일 앱 시작
cd /Users/wjs/cursor/oddiya/mobile
npm start # Metro bundler
npm run ios # iOS Simulator시나리오 1: 첫 플랜 생성
- CreatePlan 화면 진입
- Seoul, 2025-11-10, 2025-11-12, Medium 입력
- Generate 버튼 탭
- 스트리밍 진행 확인 (~6초)
- Plans 리스트에 플랜 추가 확인
시나리오 2: 캐시된 플랜 생성
- 동일한 파라미터로 재생성
- 즉시 완료 (<1초) 확인
- "💾 Cached" 배지 확인
- 중복 저장 안됨 확인
시나리오 3: 앱 재시작
- 앱 종료
- 앱 재시작
- Plans 탭 진입
- 플랜이 여전히 표시됨
# LLM Agent
tail -f /tmp/llm-agent.log | grep -E "Streaming|Cache|ERROR"
# 성공 로그:
# → Checking cache for key: plan:Seoul:2025-11-10:2025-11-12:medium
# → Cache miss, generating new plan
# → Streaming plan generation started
# → Saved plan to cache
# Plan Service
tail -f /tmp/plan-service.log | grep "PlanService"
# 성공 로그:
# [PlanService] Creating plan for user=1, destination='Seoul'
# [PlanService] → Python LLM Agent: LlmRequest(...)
# [PlanService] ← Python LLM Agent returned plan: 3 days
# [PlanService] ✅ Plan saved to database: id=1
# [PlanService] Fetching all plans for user=1
# [PlanService] Found 1 plans for user=1# Metro Console
# [Streaming] Connecting to: http://localhost:8000/api/v1/plans/generate/stream
# [Streaming] Request: {location: "Seoul", ...}
# [Streaming] Response status: 200
# [Streaming] Stream opened, reading events...
# [Streaming] Status: Seoul의 날씨 정보를 수집하고 있습니다... 10
# [Streaming] Chunk: Morning: 경복궁...
# [Streaming] Complete! Plan: Seoul 3-Day Trip
# Saving plan to database...
# Plan saved successfully!-
PlanDetail 화면 완성
- 플랜 상세 내역 표시
- 일별 일정 표시
- 예상 비용 표시
-
플랜 수정/삭제
- 수정 화면
- 삭제 확인 모달
-
오류 처리 개선
- 네트워크 오류 처리
- LLM 실패 시 재시도
- 타임아웃 처리
-
인증 통합
- OAuth 로그인 완성
- JWT 토큰 관리
- 자동 로그인
-
오프라인 지원
- AsyncStorage에 플랜 캐싱
- 오프라인 모드
-
비디오 생성
- 플랜에서 비디오 생성
- S3 업로드
- SNS 공유
- DATABASE_PERSISTENCE_COMPLETE.md - 데이터베이스 영속성 구현 상세
- MOBILE_STREAMING_TEST_GUIDE.md - 스트리밍 테스트 가이드
- STREAMING_COMPLETE_SUMMARY.md - 스트리밍 구현 전체 요약
- READY_TO_TEST_SUMMARY.md - 테스트 준비 상태
마지막 테스트: 2025-11-04 상태: ✅ 모든 기능 구현 완료, 테스트 준비 완료 다음: 모바일 앱에서 실제 테스트