DOCS/journey/troubleshooting/251122_calendar_event_registration_failure_tdd.md

9.0 KiB

캘린더 일정 등록 실패 문제 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: 자연어 시간 범위 파싱

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: 제목 추출

def test_title_extraction_from_original_message():
    test_cases = [
        ("11월 24일 월요일 오후 1시부터 4시까지 컴퍼니엑스 세미나", "컴퍼니엑스 세미나"),
    ]
    # 검증: 원본 메시지에서 날짜/시간 제거 후 제목 추출

테스트 3: 전체 파싱 워크플로우

def test_full_parsing_workflow():
    # 실제 사용자 입력 시나리오 전체 테스트
    # 날짜 추출 → 시간 범위 추출 → ISO 변환

테스트 4: skill-calendar 토큰 갱신

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 토큰 갱신 패턴 (참고)

# 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-emailskill-calendar의 토큰 갱신 로직이 중복될 수 있음
  • 공통 모듈로 분리 고려 (선택사항)

충돌 없음

  • rb8001 파싱 로직과 skill-calendar API는 독립적
  • 문제는 skill-calendar의 OAuth 토큰 관리만 해당

5. 해결 방안

5.1 즉시 해결 (우선순위 1)

skill-calendar 토큰 갱신 로직 추가

# 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)

에러 메시지 개선

# 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 단위 테스트

cd /home/admin/ivada_project/rb8001
docker exec -it rb8001 python3 tests/test_calendar_natural_language_parsing.py

6.2 통합 테스트

# 1. rb8001에서 일정 등록 요청
# 2. skill-calendar API 호출 확인
# 3. Google Calendar API 호출 확인

6.3 E2E 테스트

# 실제 사용자 시나리오 테스트
# "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. 사용자 안내: 기술적 에러를 사용자 친화적인 재인증 안내 메시지로 변환