# 캘린더 일정 등록 실패 문제 TDD 접근 **작성일**: 2025-11-22 **작성자**: Claude **목적**: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 등록 실패 문제 해결 --- ## 1. 문제 상황 ### 사용자 시나리오 ``` 사용자: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 로빙: "네, 11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나 일정을 등록해 드릴게요. 구글 캘린더에 등록해드릴까요?" 사용자: "ㅇㅇ" 로빙: "일정 등록에 실패했습니다. 날짜/시간 형식을 다시 확인해 주세요." ❌ ``` ### 로그 분석 (2025-11-22 11:23) **rb8001 로그**: ``` [Calendar] Extracted - date: 2025-11-24, time_range: 13:00 ~ 16:00, location: None, title: 컴퍼니엑스 세미나 ``` ✅ **파싱 성공**: 날짜, 시간, 제목 모두 정상 추출 **skill-calendar 로그**: ``` Failed to create event: ('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'}) ``` ❌ **문제**: Google OAuth 토큰 만료/취소 --- ## 2. 문제 원인 분석 ### 2.1 rb8001 파싱 로직 (✅ 정상) - 자연어 시간 범위 파싱: "오후 1시부터 4시까지" → "13:00 ~ 16:00" ✅ - 제목 추출: "컴퍼니엑스 세미나" ✅ - 날짜 추출: "11월 24일" → "2025-11-24" ✅ ### 2.2 skill-calendar OAuth 토큰 문제 (❌ 실패) - Google OAuth 토큰이 만료되었거나 취소됨 - 토큰 갱신 로직이 있지만 실패 (`invalid_grant` 에러) - skill-email과 동일한 패턴이지만 skill-calendar에는 제대로 적용되지 않음 --- ## 3. TDD 계획 ### 3.1 테스트 작성 (Red Phase) #### 테스트 1: 자연어 시간 범위 파싱 ```python def test_natural_language_time_range_parsing(): test_cases = [ ("오후 1시부터 4시까지", "13:00 ~ 16:00"), ("오전 9시부터 오후 5시까지", "09:00 ~ 17:00"), ("1시부터 4시까지", "01:00 ~ 04:00"), ] # 검증: extract_time_range_generic() 함수 ``` #### 테스트 2: 제목 추출 ```python def test_title_extraction_from_original_message(): test_cases = [ ("11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나", "컴퍼니엑스 세미나"), ] # 검증: 원본 메시지에서 날짜/시간 제거 후 제목 추출 ``` #### 테스트 3: 전체 파싱 워크플로우 ```python def test_full_parsing_workflow(): # 실제 사용자 입력 시나리오 전체 테스트 # 날짜 추출 → 시간 범위 추출 → ISO 변환 ``` #### 테스트 4: skill-calendar 토큰 갱신 ```python def test_token_refresh_on_expired(): # 만료된 토큰에 대해 자동 갱신 시도 # skill-email 패턴 참고 ``` ### 3.2 구현 (Green Phase) #### 단계 1: rb8001 파싱 로직 개선 (✅ 완료) - `extract_time_range_generic()`: 자연어 시간 범위 파싱 추가 - 제목 추출 로직 개선: 원본 메시지에서 직접 추출 - `calendar_approval` 이후 `calendar_confirm` 찾기 로직 개선 #### 단계 2: skill-calendar 토큰 갱신 로직 확인 - `skill-email/services/gmail_service.py` 패턴 참고 - `skill-calendar/services/google_calendar_service.py`에 동일한 로직 적용 - 토큰 갱신 실패 시 사용자에게 재인증 안내 ### 3.3 리팩터링 (Refactor Phase) - 중복 코드 제거 - 에러 처리 개선 - 로깅 개선 --- ## 4. 기존 코드 확인 ### 4.1 잘 되던 것 확인 #### skill-email 토큰 갱신 패턴 (참고) ```python # skill_email/services/gmail_service.py:64-75 if creds and hasattr(creds, 'expired') and creds.expired and creds.refresh_token: logger.info(f"Refreshing expired token for user: {user_id}") from google.auth.transport.requests import Request creds.refresh(Request()) # 갱신된 토큰 저장 if hasattr(self.creds_provider, 'save_credentials'): self.creds_provider.save_credentials(user_id, creds) ``` #### skill-calendar 현재 상태 - `get_credentials()`: 토큰 로드만 수행 - `create_event()`: 토큰 갱신 없이 바로 API 호출 - ❌ **문제**: 만료된 토큰으로 API 호출 시 `invalid_grant` 에러 ### 4.2 중복/충돌 확인 #### 중복 코드 - `skill-email`과 `skill-calendar`의 토큰 갱신 로직이 중복될 수 있음 - 공통 모듈로 분리 고려 (선택사항) #### 충돌 없음 - rb8001 파싱 로직과 skill-calendar API는 독립적 - 문제는 skill-calendar의 OAuth 토큰 관리만 해당 --- ## 5. 해결 방안 ### 5.1 즉시 해결 (우선순위 1) #### skill-calendar 토큰 갱신 로직 추가 ```python # skill-calendar/services/google_calendar_service.py async def get_credentials(self, user_id: str) -> Optional[Credentials]: creds = await self._load_credentials_from_db(user_id) if not creds: return None # 토큰 갱신 (skill-email 패턴 참고) try: if creds and hasattr(creds, 'expired') and creds.expired and creds.refresh_token: logger.info(f"Refreshing expired token for user {user_id}") from google.auth.transport.requests import Request creds.refresh(Request()) # 갱신된 토큰 저장 (DB 업데이트) await self._save_credentials_to_db(user_id, creds) logger.info(f"Refreshed token saved for user {user_id}") except Exception as e: logger.warning(f"Token refresh failed for user {user_id}: {e}") # Refresh 실패해도 계속 진행 (401 에러가 나면 그때 처리) return creds ``` ### 5.2 사용자 안내 개선 (우선순위 2) #### 에러 메시지 개선 ```python # rb8001/app/router/calendar_handler.py if not result: logger.error(f"[Calendar] Event creation failed - OAuth token may be expired") return { "success": False, "message": "일정 등록에 실패했습니다. Google 캘린더 연동을 다시 확인해 주세요." } ``` ### 5.3 장기 개선 (우선순위 3) #### OAuth 재인증 플로우 - 토큰 갱신 실패 시 사용자에게 재인증 안내 - auth-server의 OAuth 재인증 URL 생성 API 활용 --- ## 6. 테스트 실행 계획 ### 6.1 단위 테스트 ```bash cd /home/admin/ivada_project/rb8001 docker exec -it rb8001 python3 tests/test_calendar_natural_language_parsing.py ``` ### 6.2 통합 테스트 ```bash # 1. rb8001에서 일정 등록 요청 # 2. skill-calendar API 호출 확인 # 3. Google Calendar API 호출 확인 ``` ### 6.3 E2E 테스트 ```bash # 실제 사용자 시나리오 테스트 # "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 입력 # "ㅇㅇ" 승인 # Google Calendar에 일정 등록 확인 ``` --- ## 7. 참고 문서 - `DOCS/journey/plans/251114_skill_calendar_multiplatform_integration.md`: 캘린더 통합 계획 - `DOCS/journey/troubleshooting/250828_gmail_token_auto_refresh_RESOLVED.md`: Gmail 토큰 갱신 해결 사례 - `skill_email/services/gmail_service.py`: 토큰 갱신 패턴 참고 - `rb8001/tests/test_calendar_query_vs_create.py`: 기존 캘린더 테스트 --- ## 8. 해결 완료 (2025-11-22) ### 8.1 구현 완료 1. ✅ rb8001 자연어 시간 범위 파싱 개선 (`extract_time_range_generic`) 2. ✅ rb8001 제목 추출 로직 개선 (원본 메시지에서 직접 추출) 3. ✅ skill-calendar 토큰 자동 갱신 로직 추가 (`get_credentials()`) 4. ✅ skill-calendar `_save_credentials_to_db()` 메서드 구현 5. ✅ skill-calendar `invalid_grant` 에러를 401로 전파 6. ✅ rb8001 401 에러 감지 및 재인증 안내 메시지 ### 8.2 검증 완료 (2025-11-22 11:52) ``` 사용자: "11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나" 로빙: "네, 다음 주 월요일 11월 24일 오후 1시부터 4시까지 컴퍼니엑스 세미나 일정을 등록해 드릴게요. 구글 캘린더에 등록해드릴까요?" 사용자: "ㅇㅇ" 로빙: "✅ 구글 캘린더에 일정을 등록했습니다! 제목: 컴퍼니엑스 세미나 일시: 2025-11-24 13:00 ~ 16:00 장소: N/A 캘린더에서 보기" ``` ✅ **성공**: 일정 등록 완료 ### 8.3 수정된 파일 - `rb8001/app/router/calendar_handler.py`: 자연어 시간 파싱, 제목 추출, 재인증 안내 - `rb8001/app/skills/calendar_skill.py`: 401 에러 감지 및 전파 - `skill-calendar/services/google_calendar_service.py`: 토큰 자동 갱신, DB 저장 - `skill-calendar/routers/calendar.py`: `invalid_grant` → 401 변환 ### 8.4 교훈 1. **자연어 파싱**: "오후 1시부터 4시까지" 같은 자연어 시간 표현을 정규식으로 파싱 가능 2. **제목 추출**: LLM 응답이 구조화되지 않아도 원본 메시지에서 직접 추출 가능 3. **OAuth 토큰 관리**: skill-email 패턴을 참고하여 skill-calendar에도 동일한 토큰 갱신 로직 적용 4. **에러 전파**: `invalid_grant` 같은 인증 에러는 401로 명확히 전파하여 상위 레이어에서 처리 가능 5. **사용자 안내**: 기술적 에러를 사용자 친화적인 재인증 안내 메시지로 변환