# 260310 콜드메일 `skill-rag-file` 500 및 실패은닉 원인확정 리서치

tags: [coldmail, skill-rag-file, rb8001, scheduler, postgres, research]

**날짜**: 2026-03-10  
**작성자**: Codex  
**관련 파일**: `rb8001/app/services/coldmail_processor.py`, `rb8001/app/services/workflows/coldmail_workflow.py`, `rb8001/app/scheduler/jobs/coldmail_briefing.py`, `skill-rag-file/app/api/upload.py`, `skill-rag-file/.env`  
**상위 원칙**: [전역 원칙](../../../../0_VALUE/00_Principles/global-principles.md), [문서 작성 원칙](../../book/300_architecture/312_writing-principles.md), [Backend Coding Principles](../../book/300_architecture/311_backend_coding_principles.md)

## 관련 문서
- [콜드메일 `skill-rag-file` 500과 실패가시성 복구](../troubleshooting/260310_coldmail_skill_rag_file_500_stale_db_host_및_실패가시성_복구.md)
- [9시 네이버 이메일 분석 미전송과 실패 은닉 해결](../troubleshooting/260309_9시_네이버이메일분석_미전송_실패은닉_해결.md)
- [51123 임시복구 서비스 연속성 조치내역](../../../../infra/DOCS/journey/troubleshooting/260304_51123_임시복구_서비스연속성_조치내역.md)

---

## 1. 목적
- `2026-03-10 09:05` KST 콜드메일 프로세스가 "안 돈 것처럼 보인 이유"를 로그, 코드, 런타임 설정 기준으로 단일 원인까지 확정한다.
- `skill-rag-file` 업로드 `500 Internal Server Error`의 직접 원인을 확정한다.
- 실패은닉이 어떤 코드 경로에서 발생했는지 분리한다.

## 2. 범위 / 비범위
- 범위: `rb8001`, `skill-rag-file`, PostgreSQL 연결 경로, `2026-03-10 09:05` 실행 구간
- 비범위: 콜드메일 후보 선정 로직 자체의 품질, 다른 스케줄러 잡, 다른 프로젝트

## 3. 사실(Facts)
### 3-1. 09:05 작업은 실제 실행됐다
- `rb8001` 로그에 `2026-03-10 09:05:00` `Running job: Coldmail Daily Briefing`이 기록됐다.
- 같은 실행에서 콜드메일 후보 3건이 선택됐고, 각 후보 처리 중 첨부 PDF 업로드 단계가 실패했다.
- 최종 로그는 `Coldmail briefing completed: 0 emails processed`로 닫혔다.
- 같은 프로세스에서 뒤이은 다른 잡은 계속 실행됐고, APScheduler는 작업을 `executed successfully`로 기록했다.

### 3-2. 직접 실패 지점은 `skill-rag-file` 업로드였다
- `skill-rag-file` 컨테이너 로그의 같은 시각대에 `POST /api/upload HTTP/1.1" 500 Internal Server Error`가 반복 기록됐다.
- 스택트레이스는 `/app/app/api/upload.py` 내부 `await db.execute(...)` 경로에서 끝났고, 최종 예외는 `TimeoutError`였다.
- 이 시점 업로드 실패는 콜드메일 후보 수집 이후, 첨부파일 문서화 단계에서 발생했다.

### 3-3. `skill-rag-file`의 데이터베이스 주소는 stale 값이었다
- 컨테이너 환경변수에서 `DATABASE_URL=postgresql://robeings:robeings@192.168.219.45:5432/main_db`가 확인됐다.
- 현재 호스트 IP는 `192.168.0.100`, `172.17.0.1` 등으로 확인됐고 `192.168.219.45`는 현재 서버 유효 주소가 아니었다.
- 호스트에서 `192.168.219.45:5432` 연결은 timeout이 발생했다.
- 반대로 컨테이너 내부에서 `127.0.0.1:5432`와 `172.17.0.1:5432` 연결은 성공했다.

### 3-4. stale 값의 근거는 로컬 오버라이드 `.env`였다
- `skill-rag-file/.env`에 `DATABASE_URL=postgresql://robeings:robeings@192.168.219.45:5432/main_db`가 남아 있었다.
- `2026-03-04` 임시복구 문서에는 `192.168.219.45` 임시 IP 경로를 사용한 조치가 남아 있다.
- 따라서 현재 서비스는 SSOT 런타임 환경이 아니라 과거 임시 오버라이드 값을 우선 사용하고 있었다.

### 3-5. 실패은닉은 `rb8001` 상위 흐름에서 발생했다
- `rb8001/app/services/coldmail_processor.py`는 첨부 처리 실패를 `None` 반환으로 닫는 경로가 있었다.
- `rb8001/app/services/workflows/coldmail_workflow.py`는 `None` 결과를 "처리 안 된 후보"로만 누락시켰다.
- `rb8001/app/scheduler/jobs/coldmail_briefing.py`는 워크플로우 예외가 없으면 성공 종료로 기록했다.
- 그래서 하위 업로드 실패가 있어도 스케줄러는 성공처럼 보였다.

### 3-6. 수정 후 검증 결과
- `skill-rag-file/.env`의 DB 호스트를 `172.17.0.1`로 교정한 뒤 실제 팀 ID `38bdc27d-cb01-4960-867e-41733d2f3529` 업로드는 `200` 응답으로 성공했다.
- 잘못된 팀 ID는 추가 검증에서 `404 Team not found`로 반환되도록 정리했다.
- `rb8001`은 처리 실패를 `processing_failures`로 수집하고, 하나라도 있으면 스케줄러 레벨에서 예외를 올리도록 바꿨다.

## 4. 배제된 가설
- "09:05 스케줄러가 아예 돌지 않았다": 거짓. 시작 로그와 내부 처리 로그가 존재한다.
- "Neo4j unavailable 경고가 직접 원인이다": 거짓. 경고는 있었지만 콜드메일 흐름은 첨부 업로드 단계까지 진행됐다.
- "첨부 PDF 자체가 손상돼서 실패했다": 직접 원인 아님. 동일 시점 `skill-rag-file` 로그의 실제 예외는 DB timeout이었다.
- "운영 DB 자체가 완전히 죽어 있었다": 거짓. 올바른 호스트 경로로는 연결 가능했고, 수정 후 업로드가 성공했다.

## 5. 해석(Interpretation)
### 5-1. 09:05 운영 실패의 직접 원인
- 직접 원인은 `skill-rag-file`이 오래된 DB 호스트 `192.168.219.45`를 사용한 것이다.
- 이 값 때문에 첨부 저장용 DB insert가 timeout으로 실패했고, 콜드메일 후보 3건 모두 문서화에 실패했다.

### 5-2. 사용자가 "안 돌았다"고 체감한 이유
- 실제 작업은 실행됐지만 핵심 산출물 생성에 모두 실패했다.
- 동시에 상위 잡이 실패를 예외로 승격하지 않아 스케줄러 성공 로그만 남았기 때문에, 운영 관측상 "안 돌았다"로 보이게 됐다.

### 5-3. 왜 `500`이 문제였는가
- 원래 원인은 DB 연결 timeout이므로 일반 `500`보다 구체적인 비가용성 오류로 분리되어야 한다.
- 또 팀 미존재 같은 입력/무결성 오류까지 `503`이나 `500`으로 뭉개면 원인성이 깨진다.
- 따라서 "연결 불가 계열만 `503`", "존재하지 않는 팀은 `404`", "콜드메일 처리 실패는 스케줄러 실패"가 맞다.

## 6. 결론
### 결론 A. 09:05 콜드메일은 실행됐지만 업무적으로 실패했다
- 스케줄러 실행 성공과 업무 성공은 달랐다.
- 운영 기준 최종 판정은 "실행은 됐고, 첨부 업로드 실패로 결과 생성에 실패"다.

### 결론 B. `skill-rag-file` 500의 단일 직접 원인은 stale DB 호스트다
- `192.168.219.45`는 과거 임시복구 경로의 잔존값이었다.
- 현재 정상 경로는 컨테이너에서 접근 가능한 `172.17.0.1:5432`다.

### 결론 C. 실패은닉의 단일 직접 원인은 `None` 기반 누락 처리다
- 하위 실패가 예외 대신 빈 결과처럼 흡수되면서 APScheduler 성공 로그가 남았다.
- 이 경로를 끊어야 운영 로그와 실제 결과가 일치한다.

## 7. 이 이슈를 닫는 기준
- `skill-rag-file` 업로드가 실제 운영 팀 ID에서 `200`이어야 한다.
- DB 연결 불가 계열만 `503`으로 남아야 한다.
- 잘못된 팀 ID는 `404`여야 한다.
- 첨부 업로드 실패가 다시 발생하면 `rb8001` 스케줄러 로그는 실패로 남아야 한다.

## 8. 남은 확인 포인트
- 현재 로컬 셸의 `pytest`는 프로젝트 런타임 의존성 경로가 맞지 않아 바로 실행되지 않았다.
- 따라서 이번 문서의 검증 근거는 컨테이너 재배포, 실제 업로드 응답 코드, 컨테이너 로그, `py_compile` 기준이다.
