# 9시 네이버 이메일 분석 미전송과 실패 은닉 해결

tags: [naverworks, email, briefing, scheduler, timeout, fallback]

**날짜**: 2026-03-09  
**작성자**: Codex  
**관련 파일**: `rb8001/app/services/skills/naverworks_briefing.py`, `rb8001/app/scheduler/jobs/naverworks_briefing.py`, `skill-email/services/naverworks_provider.py`  
**상위 원칙**: [문서 작성 원칙](../../book/300_architecture/312_writing-principles.md), [Backend Coding Principles](../../book/300_architecture/311_backend_coding_principles.md)

## 관련 문서
- [9시 네이버 이메일 분석 미전송 실패 은닉 리서치](../research/260309_9시_네이버이메일분석_미전송_실패은닉_리서치.md)
- [NAVER WORKS Refresh unauthorized_client 장애 및 재인증 복구 기록](./260227_naverworks_refresh_unauthorized_client_reauth_recovery.md)

---

## 문제 상황
- `2026-03-09 09:00:00` KST에 `naverworks_daily` 작업은 실제 실행됐다.
- `2026-03-09 09:00:30` KST에 `rb8001 -> skill-email /messages` 호출이 `httpx.ReadTimeout`으로 실패했다.
- 그런데 `rb8001/app/services/skills/naverworks_briefing.py:57` 경로가 이 실패를 `No emails`처럼 처리해, 운영자가 "메일이 없었다"고 오인할 수 있는 상태가 됐다.
- `rb8001/app/scheduler/jobs/naverworks_briefing.py:87`도 예외를 다시 올리지 않아 스케줄러에는 성공처럼 남았다.

## 해결 방안
- `rb8001/app/services/skills/naverworks_briefing.py:16`: `BriefingFetchError`를 추가해 메일 조회 실패를 빈 결과와 분리했다.
- `rb8001/app/services/skills/naverworks_briefing.py:57`: 실제 빈 목록일 때만 `No emails in the configured briefing window`를 기록하도록 바꿨다.
- `rb8001/app/services/skills/naverworks_briefing.py:109`: `httpx.ReadTimeout`, `httpx.HTTPError`, 비정상 응답, 잘못된 payload를 `BriefingFetchError`로 올리도록 바꿨다.
- `rb8001/app/scheduler/jobs/naverworks_briefing.py:89`: 스케줄러 래퍼가 `ImportError`와 일반 예외를 다시 올려 APScheduler가 실패로 기록하게 바꿨다.
- `skill-email/services/naverworks_provider.py:38`: 토큰 만료 판단 기준을 `datetime.utcnow()`로 명시해 UTC naive DB 값과 비교 기준을 맞췄다.
- `skill-email/services/naverworks_provider.py:49`, `skill-email/services/naverworks_provider.py:197`, `skill-email/services/naverworks_provider.py:357`: 컨텍스트 조회, 외부 NAVER WORKS API 호출, refresh 호출에 단계별 추적 로그와 timeout 분기 로그를 추가했다.

## 검증
- `docker exec -e PYTHONPATH=/code rb8001 pytest -q tests/test_naverworks_briefing.py` 결과 `10 passed`
- `https://ro-being.com/rb8001/health` 응답 `200`
- `https://ro-being.com/skill-email/health` 응답 `200`
- `skill-email /messages` 재호출 시 `200` 응답과 단계별 로그(`context lookup`, `token state`, `API request finished`)를 확인했다.

## 구현 완료
- `rb8001` 커밋: `6b2280e` (`fix: surface naverworks briefing failures`)
- `skill-email` 커밋: `7a57aae` (`fix: add naverworks fetch tracing`)

## 교훈
- 외부 API 조회 실패와 "실제 0건"은 같은 값으로 처리하면 안 된다.
- 스케줄러 성공 로그는 실제 작업 성공과 같지 않으므로, 예외를 다시 올려 관측 가능한 실패로 남겨야 한다.
- 시간대가 섞인 토큰 만료 판단은 장애를 숨기기 쉬우므로, DB 타입과 비교 기준을 같은 시간 축으로 맞춰야 한다.
