## 들어가며
분산 시스템에서 메시지 큐를 운영하다 보면 재시도(retry) 로직은 필수적입니다. 특히 외부 API 호출 시 rate limit에 걸렸을 때 메시지를 재시도 큐로 보내는 패턴은 매우 흔합니다. 하지만 이 과정에서 메시지가 영구적으로 '처리 중(processing)' 상태에 갇혀버리는 문제를 경험한 적이 있으신가요?
오늘은 실제 프로덕션 환경에서 발생한 메시지 큐 교착 상태 문제와 그 해결 과정을 공유합니다.
## 문제 상황: 메시지가 사라지다
### 증상
재시도 큐(retry_queue)에서 처리되어야 할 메시지들이 계속 '처리 중' 상태로 남아있고, 실제로는 처리되지 않는 현상이 발생했습니다.
### 원인 분석
문제의 근본 원인은 메시지 상태 전이(state transition)와 클레임(claim) 로직 사이의 불일치였습니다.
**정상 흐름:**
1. 워커가 메시지를 가져와 처리 시작 → 상태: `pending` → `processing`
2. Rate limit 발생 → 재시도 큐에 추가
3. 일정 시간 후 재시도 큐가 메시지 재처리 시도
**문제 발생 지점:**
재시도 큐가 메시지를 다시 처리하려 할 때, 기존 클레임 조건은 이렇게 설정되어 있었습니다:
```sql
SELECT * FROM messages
WHERE id = $1
AND status IN ('pending', 'queued', 'routing', 'pending_local')
FOR UPDATE
```
문제는 메시지가 이미 `processing` 상태라는 점입니다. 위 쿼리는 `processing` 상태인 메시지를 클레임할 수 없으므로:
- 클레임 실패 → "already claimed by another worker" 메시지 출력
- 재시도 로직은 메시지를 건너뜀
- 메시지는 영구히 `processing` 상태로 남음
## 해결 방법: Self-Reclaim 패턴
핵심 아이디어는 **같은 워커가 자신이 이미 클레임한 메시지를 다시 클레임할 수 있도록** 허용하는 것입니다.
### 개선된 클레임 쿼리
```sql
SELECT * FROM messages
WHERE id = $1
AND (
status IN ('pending', 'queued', 'routing', 'pending_local')
OR (status = 'processing' AND worker_id = $2) -- 핵심: 같은 워커면 재클레임 허용
)
FOR UPDATE
```
### 동작 원리
1. **새로운 메시지**: `status`가 `pending` 등이므로 첫 번째 조건으로 클레임
2. **재시도 메시지**: `status`가 `processing`이지만 `worker_id`가 현재 워커와 같으므로 두 번째 조건으로 클레임
3. **다른 워커의 메시지**: 두 조건 모두 만족하지 않아 클레임 실패 (정상적인 충돌 방지)
## 적용 결과
- ✅ Rate limit 재시도 시 메시지가 정상적으로 재처리됨
- ✅ 더 이상 `processing` 상태에 갇힌 메시지 없음
- ✅ 다른 워커와의 충돌 방지 기능은 그대로 유지
## 핵심 교훈
### 1. 상태 전이 설계 시 재시도 시나리오 고려
메시지 큐 설계 시 "재시도도 하나의 정상 흐름"으로 간주하고 상태 전이 다이어그램에 명시적으로 포함시켜야 합니다.
### 2. 클레임 로직의 멱등성(Idempotency)
같은 워커가 같은 메시지를 여러 번 클레임하는 것은 안전해야 합니다. 이는 재시도뿐 아니라 네트워크 타임아웃 등 다양한 엣지 케이스에서 유용합니다.
### 3. 모니터링의 중요성
`processing` 상태로 일정 시간 이상(예: 1시간) 남아있는 메시지를 감지하는 알람을 설정하면 이런 문제를 조기에 발견할 수 있습니다.
## 마치며
분산 시스템에서 재시도 로직은 단순해 보이지만 상태 관리와 결합되면 복잡한 엣지 케이스를 만들어냅니다. 이번 사례처럼 "같은 워커의 재클레임 허용" 패턴은 많은 메시지 큐 시스템에서 유용하게 활용될 수 있습니다.
여러분의 시스템에서도 비슷한 문제를 겪고 계신가요? 댓글로 경험을 공유해주세요!